From 32a31e0b4eeda1bffaeb309ddd3e5377858a9aca Mon Sep 17 00:00:00 2001 From: Joshua Cold Date: Thu, 14 May 2026 09:14:57 -0600 Subject: [PATCH 01/11] fix(build): Install as Agent-Teams-UI, display with spaces. This changes how the app's install directory functions so there are not spaces in the destination path, but adds spaces to the Apps display name so it should look the same in installed environments when launching the application. fixes: #111 --- .github/workflows/release.yml | 2 +- package.json | 7 +++++-- src/main/index.ts | 4 ++++ test/main/services/team/ClaudeBinaryResolver.test.ts | 4 ++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ab97a28..41f5883a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -254,7 +254,7 @@ jobs: run: ${{ matrix.dist_command }} --publish never - name: Validate packaged bundle (macOS ${{ matrix.arch }}) - run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin ${{ matrix.arch }} + run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent-Teams-UI.app" darwin ${{ matrix.arch }} - name: Upload assets to release if: startsWith(github.ref, 'refs/tags/v') diff --git a/package.json b/package.json index b6ee7177..d3f74fd9 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,7 @@ }, "build": { "appId": "com.agent-teams.app", - "productName": "Agent Teams UI", + "productName": "Agent-Teams-UI", "directories": { "output": "release" }, @@ -305,7 +305,10 @@ "pacman" ], "icon": "resources/icons/png", - "category": "Development" + "category": "Development", + "desktop": { + "Name": "Agent Teams UI" + } }, "appImage": { "artifactName": "Agent.Teams.AI-${version}.${ext}" diff --git a/src/main/index.ts b/src/main/index.ts index 4c914fc7..ba782746 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -107,6 +107,10 @@ import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; +// productName uses hyphens to avoid spaces in the Linux install path (/opt/Agent-Teams-UI/). +// Restore the human-readable display name for macOS menus and Windows system dialogs. +app.setName('Agent Teams UI'); + import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { registerRendererLogHandlers } from './ipc/rendererLogs'; diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts index 2378db8e..810a90eb 100644 --- a/test/main/services/team/ClaudeBinaryResolver.test.ts +++ b/test/main/services/team/ClaudeBinaryResolver.test.ts @@ -72,7 +72,7 @@ describe('ClaudeBinaryResolver', () => { }); process.cwd = vi.fn(() => workspaceRoot); Object.defineProperty(process, 'resourcesPath', { - value: '/Applications/Agent Teams UI.app/Contents/Resources', + value: '/Applications/Agent-Teams-UI.app/Contents/Resources', configurable: true, writable: true, }); @@ -200,7 +200,7 @@ describe('ClaudeBinaryResolver', () => { it('prefers the bundled runtime binary for packaged agent_teams_orchestrator builds', async () => { const expectedBinary = path.join( - '/Applications/Agent Teams UI.app/Contents/Resources', + '/Applications/Agent-Teams-UI.app/Contents/Resources', 'runtime', 'claude-multimodel' ); From b3a10ff2cadc9cc2765c5b9191b5529eedd78d66 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 14 May 2026 20:05:48 +0300 Subject: [PATCH 02/11] fix(build): isolate Linux package name override --- .github/workflows/release.yml | 2 +- package.json | 19 ++-- scripts/electron-builder/dist.mjs | 97 +++++++++++++++++++ src/main/index.ts | 4 - .../build/electronBuilderDistScript.test.ts | 52 ++++++++++ .../team/ClaudeBinaryResolver.test.ts | 4 +- 6 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 scripts/electron-builder/dist.mjs create mode 100644 test/main/build/electronBuilderDistScript.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41f5883a..1ab97a28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -254,7 +254,7 @@ jobs: run: ${{ matrix.dist_command }} --publish never - name: Validate packaged bundle (macOS ${{ matrix.arch }}) - run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent-Teams-UI.app" darwin ${{ matrix.arch }} + run: node ./scripts/electron-builder/verifyBundle.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin ${{ matrix.arch }} - name: Upload assets to release if: startsWith(github.ref, 'refs/tags/v') diff --git a/package.json b/package.json index d3f74fd9..b61c4d8d 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,12 @@ "team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build", - "dist": "electron-builder --mac --win --linux", - "dist:mac": "electron-builder --mac", - "dist:mac:arm64": "electron-builder --mac --arm64", - "dist:mac:x64": "electron-builder --mac --x64", - "dist:win": "electron-builder --win", - "dist:linux": "electron-builder --linux", + "dist": "node ./scripts/electron-builder/dist.mjs --mac --win --linux", + "dist:mac": "node ./scripts/electron-builder/dist.mjs --mac", + "dist:mac:arm64": "node ./scripts/electron-builder/dist.mjs --mac --arm64", + "dist:mac:x64": "node ./scripts/electron-builder/dist.mjs --mac --x64", + "dist:win": "node ./scripts/electron-builder/dist.mjs --win", + "dist:linux": "node ./scripts/electron-builder/dist.mjs --linux", "preview": "electron-vite preview", "typecheck": "tsc --noEmit", "typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck && pnpm --filter agent-teams-mcp typecheck:test", @@ -230,7 +230,7 @@ }, "build": { "appId": "com.agent-teams.app", - "productName": "Agent-Teams-UI", + "productName": "Agent Teams UI", "directories": { "output": "release" }, @@ -305,10 +305,7 @@ "pacman" ], "icon": "resources/icons/png", - "category": "Development", - "desktop": { - "Name": "Agent Teams UI" - } + "category": "Development" }, "appImage": { "artifactName": "Agent.Teams.AI-${version}.${ext}" diff --git a/scripts/electron-builder/dist.mjs b/scripts/electron-builder/dist.mjs new file mode 100644 index 00000000..e3c72615 --- /dev/null +++ b/scripts/electron-builder/dist.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const require = createRequire(import.meta.url); + +const PLATFORM_FLAGS = new Map([ + ['--mac', 'mac'], + ['-m', 'mac'], + ['--win', 'win'], + ['-w', 'win'], + ['--linux', 'linux'], + ['-l', 'linux'], +]); + +const PLATFORM_ARGS = { + mac: '--mac', + win: '--win', + linux: '--linux', +}; + +const LINUX_PACKAGE_NAME_OVERRIDES = [ + '--config.productName=Agent-Teams-UI', + '--config.linux.desktop.entry.Name=Agent Teams UI', +]; + +export function buildElectronBuilderInvocations(argv) { + const targets = []; + const sharedArgs = []; + + for (const arg of argv) { + const target = PLATFORM_FLAGS.get(arg); + if (target) { + if (!targets.includes(target)) { + targets.push(target); + } + continue; + } + sharedArgs.push(arg); + } + + if (targets.length === 0) { + return [{ args: sharedArgs }]; + } + + return targets.map((target) => ({ + args: [ + PLATFORM_ARGS[target], + ...sharedArgs, + ...(target === 'linux' ? LINUX_PACKAGE_NAME_OVERRIDES : []), + ], + })); +} + +async function runElectronBuilder(args) { + const cliPath = require.resolve('electron-builder/cli.js'); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, ...args], { + stdio: 'inherit', + env: process.env, + }); + + child.on('error', reject); + child.on('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`electron-builder failed with ${signal ?? `exit code ${code}`}`)); + }); + }); +} + +async function main(argv) { + const invocations = buildElectronBuilderInvocations(argv); + + if (process.env.ELECTRON_BUILDER_DIST_DRY_RUN === '1') { + console.log( + JSON.stringify( + invocations.map((invocation) => invocation.args), + null, + 2 + ) + ); + return; + } + + for (const invocation of invocations) { + await runElectronBuilder(invocation.args); + } +} + +const entryPointUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entryPointUrl === import.meta.url) { + await main(process.argv.slice(2)); +} diff --git a/src/main/index.ts b/src/main/index.ts index ba782746..4c914fc7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -107,10 +107,6 @@ import { app, BrowserWindow, ipcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; -// productName uses hyphens to avoid spaces in the Linux install path (/opt/Agent-Teams-UI/). -// Restore the human-readable display name for macOS menus and Windows system dialogs. -app.setName('Agent Teams UI'); - import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { registerRendererLogHandlers } from './ipc/rendererLogs'; diff --git a/test/main/build/electronBuilderDistScript.test.ts b/test/main/build/electronBuilderDistScript.test.ts new file mode 100644 index 00000000..dcb80d48 --- /dev/null +++ b/test/main/build/electronBuilderDistScript.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const scriptUrl = pathToFileURL(`${process.cwd()}/scripts/electron-builder/dist.mjs`).href; + +describe('electron-builder dist wrapper', () => { + it('splits multi-platform builds so Linux-only package name overrides do not affect macOS or Windows', async () => { + const { buildElectronBuilderInvocations } = await import(scriptUrl); + + expect( + buildElectronBuilderInvocations(['--mac', '--win', '--linux', '--publish', 'never']) + ).toEqual([ + { args: ['--mac', '--publish', 'never'] }, + { args: ['--win', '--publish', 'never'] }, + { + args: [ + '--linux', + '--publish', + 'never', + '--config.productName=Agent-Teams-UI', + '--config.linux.desktop.entry.Name=Agent Teams UI', + ], + }, + ]); + }); + + it('adds the filesystem-safe package name override to Linux-only builds', async () => { + const { buildElectronBuilderInvocations } = await import(scriptUrl); + + expect(buildElectronBuilderInvocations(['--linux', '--publish', 'never'])).toEqual([ + { + args: [ + '--linux', + '--publish', + 'never', + '--config.productName=Agent-Teams-UI', + '--config.linux.desktop.entry.Name=Agent Teams UI', + ], + }, + ]); + }); + + it('leaves macOS arch-specific builds unchanged', async () => { + const { buildElectronBuilderInvocations } = await import(scriptUrl); + + expect(buildElectronBuilderInvocations(['--mac', '--arm64', '--publish', 'never'])).toEqual([ + { args: ['--mac', '--arm64', '--publish', 'never'] }, + ]); + }); +}); diff --git a/test/main/services/team/ClaudeBinaryResolver.test.ts b/test/main/services/team/ClaudeBinaryResolver.test.ts index 810a90eb..2378db8e 100644 --- a/test/main/services/team/ClaudeBinaryResolver.test.ts +++ b/test/main/services/team/ClaudeBinaryResolver.test.ts @@ -72,7 +72,7 @@ describe('ClaudeBinaryResolver', () => { }); process.cwd = vi.fn(() => workspaceRoot); Object.defineProperty(process, 'resourcesPath', { - value: '/Applications/Agent-Teams-UI.app/Contents/Resources', + value: '/Applications/Agent Teams UI.app/Contents/Resources', configurable: true, writable: true, }); @@ -200,7 +200,7 @@ describe('ClaudeBinaryResolver', () => { it('prefers the bundled runtime binary for packaged agent_teams_orchestrator builds', async () => { const expectedBinary = path.join( - '/Applications/Agent-Teams-UI.app/Contents/Resources', + '/Applications/Agent Teams UI.app/Contents/Resources', 'runtime', 'claude-multimodel' ); From 0e2be0348e51a386023615241f576b444b0ffae5 Mon Sep 17 00:00:00 2001 From: Jonas Korani <88804223+Therealkorris@users.noreply.github.com> Date: Fri, 15 May 2026 21:04:01 +0200 Subject: [PATCH 03/11] fix(team): make Windows child env setup fail open Prepare writable Windows temp/cache env for agent child processes without blocking team launch.\n\n- fail open if cache roots cannot be created or written\n- keep hard child-process preflight out of launch path\n- cover Windows env setup and fail-open behavior --- .../agentChildProcessPreflight.test.ts | 159 +++++++++++++++ .../runtime/agentChildProcessPreflight.ts | 192 ++++++++++++++++++ .../services/team/TeamProvisioningService.ts | 5 + 3 files changed, 356 insertions(+) create mode 100644 src/main/services/runtime/agentChildProcessPreflight.test.ts create mode 100644 src/main/services/runtime/agentChildProcessPreflight.ts diff --git a/src/main/services/runtime/agentChildProcessPreflight.test.ts b/src/main/services/runtime/agentChildProcessPreflight.test.ts new file mode 100644 index 00000000..6ed09514 --- /dev/null +++ b/src/main/services/runtime/agentChildProcessPreflight.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment node +import { + applyAgentChildProcessWritableEnv, + prepareAgentChildProcessWritableEnv, +} from '@main/services/runtime/agentChildProcessPreflight'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const originalPlatform = process.platform; + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + writable: true, + }); +} + +describe('agent child-process writable env', () => { + let tmpRoot: string; + + beforeEach(() => { + setPlatform('win32'); + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-child-env-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setPlatform(originalPlatform); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('does not mutate env on non-Windows platforms', async () => { + setPlatform('darwin'); + const env: NodeJS.ProcessEnv = { + TEMP: path.join(tmpRoot, 'existing-temp'), + }; + + const result = await prepareAgentChildProcessWritableEnv(env, { home: tmpRoot }); + + expect(result).toEqual({ applied: false }); + expect(env).toEqual({ + TEMP: path.join(tmpRoot, 'existing-temp'), + }); + }); + + it('prepares stable writable cache and temp env for Windows agents', async () => { + const home = path.join(tmpRoot, 'home'); + const env: NodeJS.ProcessEnv = { + COMSPEC: 'cmd.exe', + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + }; + + const result = await prepareAgentChildProcessWritableEnv(env, { home }); + + const expectedBase = path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache'); + expect(result).toEqual({ applied: true, cacheBase: expectedBase }); + expect(env.TEMP).toBe(path.join(expectedBase, 'tmp')); + expect(env.TMP).toBe(path.join(expectedBase, 'tmp')); + expect(env.TMPDIR).toBe(path.join(expectedBase, 'tmp')); + expect(env.npm_config_cache).toBe(path.join(expectedBase, 'npm-cache')); + expect(env.NPM_CONFIG_CACHE).toBe(path.join(expectedBase, 'npm-cache')); + expect(env.GRADLE_USER_HOME).toBe(path.join(expectedBase, 'gradle-home')); + expect(env.ANDROID_USER_HOME).toBe(path.join(expectedBase, 'android-home')); + expect(env.ANDROID_SDK_HOME).toBe(path.join(expectedBase, 'android-home')); + expect(env.npm_config_script_shell).toBe('cmd.exe'); + expect(env.AGENT_STUDIO_NPM_CMD).toBe('npm.cmd'); + expect(env.AGENT_STUDIO_NPX_CMD).toBe('npx.cmd'); + expect(env.GRADLE_OPTS).toContain('-Djava.io.tmpdir='); + expect(env.JAVA_TOOL_OPTIONS).toContain('-Djava.io.tmpdir='); + + await expect(fs.promises.access(path.join(expectedBase, 'tmp'))).resolves.toBeUndefined(); + await expect(fs.promises.access(path.join(expectedBase, 'npm-cache'))).resolves.toBeUndefined(); + await expect( + fs.promises.access(path.join(expectedBase, 'gradle-home')) + ).resolves.toBeUndefined(); + await expect( + fs.promises.access(path.join(expectedBase, 'android-home')) + ).resolves.toBeUndefined(); + + for (const dirName of ['tmp', 'npm-cache', 'gradle-home', 'android-home']) { + const entries = await fs.promises.readdir(path.join(expectedBase, dirName)); + const probeEntries = entries.filter((entry) => + entry.startsWith('.agent-studio-write-probe-') + ); + expect(probeEntries).toEqual([]); + } + }); + + it('fails open and leaves existing env untouched when cache dirs cannot be created', async () => { + const home = path.join(tmpRoot, 'home'); + const originalTemp = path.join(tmpRoot, 'existing-temp'); + const env: NodeJS.ProcessEnv = { + COMSPEC: 'cmd.exe', + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + TEMP: originalTemp, + TMP: originalTemp, + }; + vi.spyOn(fs.promises, 'mkdir').mockRejectedValueOnce(new Error('EACCES')); + + const result = await prepareAgentChildProcessWritableEnv(env, { home }); + + expect(result.applied).toBe(false); + expect(result.cacheBase).toBe( + path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache') + ); + expect(result.warning).toContain('keeping existing temp/cache env'); + expect(env).toEqual({ + COMSPEC: 'cmd.exe', + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + TEMP: originalTemp, + TMP: originalTemp, + }); + }); + + it('fails open and leaves existing env untouched when cache dirs are not writable', async () => { + const home = path.join(tmpRoot, 'home'); + const originalTemp = path.join(tmpRoot, 'existing-temp'); + const env: NodeJS.ProcessEnv = { + COMSPEC: 'cmd.exe', + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + TEMP: originalTemp, + TMP: originalTemp, + }; + vi.spyOn(fs.promises, 'writeFile').mockRejectedValueOnce(new Error('EPERM')); + + const result = await prepareAgentChildProcessWritableEnv(env, { home }); + + expect(result.applied).toBe(false); + expect(result.cacheBase).toBe( + path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache') + ); + expect(result.warning).toContain('failed writable check'); + expect(result.warning).toContain('EPERM'); + expect(env).toEqual({ + COMSPEC: 'cmd.exe', + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + TEMP: originalTemp, + TMP: originalTemp, + }); + }); + + it('keeps the synchronous env applicator available for prepared directories', () => { + const home = path.join(tmpRoot, 'home'); + const env: NodeJS.ProcessEnv = { + COMSPEC: 'cmd.exe', + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + }; + + applyAgentChildProcessWritableEnv(env, { home }); + + const expectedBase = path.join(home, 'AppData', 'Local', 'AgentStudio', 'runner-cache'); + expect(env.TEMP).toBe(path.join(expectedBase, 'tmp')); + expect(env.TMP).toBe(path.join(expectedBase, 'tmp')); + expect(env.TMPDIR).toBe(path.join(expectedBase, 'tmp')); + }); +}); diff --git a/src/main/services/runtime/agentChildProcessPreflight.ts b/src/main/services/runtime/agentChildProcessPreflight.ts new file mode 100644 index 00000000..cd68b7c1 --- /dev/null +++ b/src/main/services/runtime/agentChildProcessPreflight.ts @@ -0,0 +1,192 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface AgentChildProcessEnvOptions { + home?: string; +} + +export interface AgentChildProcessWritableEnvResult { + applied: boolean; + cacheBase?: string; + warning?: string; +} + +interface AgentChildProcessWritableEnvPaths { + cacheBase: string; + tempRoot: string; + npmCache: string; + gradleHome: string; + androidHome: string; + commandShell: string; +} + +interface AgentChildProcessWritableDirectory { + label: string; + dir: string; +} + +function firstNonEmpty(...values: (string | undefined | null)[]): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + return undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function setPathEnv(env: NodeJS.ProcessEnv, key: string, value: string): string { + env[key] = value; + return value; +} + +function appendJavaTmpDirOption(current: string | undefined, tempRoot: string): string { + if (current?.includes('-Djava.io.tmpdir=')) { + return current; + } + const escapedTempRoot = tempRoot.replace(/"/g, '\\"'); + const prefix = current?.trim() ? `${current.trim()} ` : ''; + return `${prefix}-Djava.io.tmpdir="${escapedTempRoot}"`; +} + +function getRuntimeCacheBase(env: NodeJS.ProcessEnv, home: string): string { + const explicit = firstNonEmpty(env.AGENT_STUDIO_RUNNER_CACHE_ROOT, env.STUDIO_AGENT_CACHE_ROOT); + if (explicit) { + return path.resolve(explicit); + } + + const localAppData = firstNonEmpty(env.LOCALAPPDATA) ?? path.join(home, 'AppData', 'Local'); + return path.join(localAppData, 'AgentStudio', 'runner-cache'); +} + +function resolveWritableEnvPaths( + env: NodeJS.ProcessEnv, + options: AgentChildProcessEnvOptions +): AgentChildProcessWritableEnvPaths { + const home = firstNonEmpty(options.home, env.USERPROFILE, env.HOME, os.homedir()) ?? os.tmpdir(); + const cacheBase = getRuntimeCacheBase(env, home); + const tempRoot = path.join(cacheBase, 'tmp'); + const commandShell = + firstNonEmpty(env.ComSpec, env.COMSPEC, process.env.ComSpec, process.env.COMSPEC) ?? 'cmd.exe'; + + return { + cacheBase, + tempRoot, + npmCache: path.join(cacheBase, 'npm-cache'), + gradleHome: path.join(cacheBase, 'gradle-home'), + androidHome: path.join(cacheBase, 'android-home'), + commandShell, + }; +} + +function applyResolvedWritableEnv( + env: NodeJS.ProcessEnv, + paths: AgentChildProcessWritableEnvPaths +): NodeJS.ProcessEnv { + setPathEnv(env, 'TEMP', paths.tempRoot); + setPathEnv(env, 'TMP', paths.tempRoot); + setPathEnv(env, 'TMPDIR', paths.tempRoot); + setPathEnv(env, 'npm_config_cache', paths.npmCache); + setPathEnv(env, 'NPM_CONFIG_CACHE', paths.npmCache); + setPathEnv(env, 'GRADLE_USER_HOME', paths.gradleHome); + setPathEnv(env, 'ANDROID_USER_HOME', paths.androidHome); + setPathEnv(env, 'ANDROID_SDK_HOME', paths.androidHome); + setPathEnv(env, 'npm_config_script_shell', paths.commandShell); + setPathEnv(env, 'AGENT_STUDIO_NPM_CMD', 'npm.cmd'); + setPathEnv(env, 'AGENT_STUDIO_NPX_CMD', 'npx.cmd'); + + env.GRADLE_OPTS = appendJavaTmpDirOption(env.GRADLE_OPTS, paths.tempRoot); + env.JAVA_TOOL_OPTIONS = appendJavaTmpDirOption(env.JAVA_TOOL_OPTIONS, paths.tempRoot); + + return env; +} + +async function ensureWritableDirectory(root: AgentChildProcessWritableDirectory): Promise { + try { + await fs.promises.mkdir(root.dir, { recursive: true }); + + const probePath = path.join( + root.dir, + `.agent-studio-write-probe-${process.pid}-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}.tmp` + ); + let probeCreated = false; + let cleanupError: unknown; + + try { + await fs.promises.writeFile(probePath, 'ok', 'utf8'); + probeCreated = true; + + const written = await fs.promises.readFile(probePath, 'utf8'); + if (written !== 'ok') { + throw new Error('write probe read back unexpected content'); + } + } finally { + if (probeCreated) { + try { + await fs.promises.unlink(probePath); + } catch (error) { + cleanupError = error; + } + } + } + + if (cleanupError) { + throw new Error(`write probe cleanup failed: ${errorMessage(cleanupError)}`); + } + } catch (error) { + throw new Error(`${root.label} at ${root.dir} failed writable check: ${errorMessage(error)}`); + } +} + +export function applyAgentChildProcessWritableEnv( + env: NodeJS.ProcessEnv, + options: AgentChildProcessEnvOptions = {} +): NodeJS.ProcessEnv { + if (process.platform !== 'win32') { + return env; + } + + return applyResolvedWritableEnv(env, resolveWritableEnvPaths(env, options)); +} + +export async function prepareAgentChildProcessWritableEnv( + env: NodeJS.ProcessEnv, + options: AgentChildProcessEnvOptions = {} +): Promise { + if (process.platform !== 'win32') { + return { applied: false }; + } + + const paths = resolveWritableEnvPaths(env, options); + const dirs: AgentChildProcessWritableDirectory[] = [ + { label: 'TEMP/TMP/TMPDIR', dir: paths.tempRoot }, + { label: 'npm cache', dir: paths.npmCache }, + { label: 'Gradle home', dir: paths.gradleHome }, + { label: 'Android home', dir: paths.androidHome }, + ]; + try { + const checks = await Promise.allSettled(dirs.map((dir) => ensureWritableDirectory(dir))); + const failed = checks.find((check) => check.status === 'rejected'); + if (failed?.status === 'rejected') { + throw failed.reason; + } + + applyResolvedWritableEnv(env, paths); + return { applied: true, cacheBase: paths.cacheBase }; + } catch (error) { + return { + applied: false, + cacheBase: paths.cacheBase, + warning: + `Windows agent writable cache setup skipped for ${paths.cacheBase}; ` + + `keeping existing temp/cache env. Details: ${errorMessage(error)}`, + }; + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 94fa5f64..4842161e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -51,6 +51,7 @@ import { } from '@features/workspace-trust/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; +import { prepareAgentChildProcessWritableEnv } from '@main/services/runtime/agentChildProcessPreflight'; import { getAppIconPath } from '@main/utils/appIcon'; import { execCli, @@ -33350,6 +33351,10 @@ export class TeamProvisioningService { }); const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId]; const providerEnv = providerEnvResult.env; + const writableEnvResult = await prepareAgentChildProcessWritableEnv(providerEnv, { home }); + if (writableEnvResult.warning) { + logger.warn(`[TeamProvisioningService] ${writableEnvResult.warning}`); + } if (options?.includeCodexTeammateAuth && resolvedProviderId !== 'codex') { await this.providerConnectionService.augmentConfiguredConnectionEnv( providerEnv, From 678d12219a3c2fe953fb952101d6348c9acb26e0 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 12:15:10 +0300 Subject: [PATCH 04/11] fix(opencode): prevent Windows live runtime hangs --- scripts/lib/opencode-live-preflight.mjs | 44 ++++++++++++++++--- .../services/team/TeamProvisioningService.ts | 8 +++- .../team/TeamProvisioningService.test.ts | 19 ++++---- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs index 73661131..a765c3ac 100644 --- a/scripts/lib/opencode-live-preflight.mjs +++ b/scripts/lib/opencode-live-preflight.mjs @@ -97,6 +97,7 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, }); let output = ''; let spawnError = ''; @@ -138,12 +139,17 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { } } -function stopChild(child) { +async function stopChild(child) { + if (child.exitCode != null || child.killed) { + return; + } + + if (process.platform === 'win32' && child.pid) { + await taskkillProcessTree(child.pid); + return; + } + return new Promise((resolve) => { - if (child.exitCode != null || child.killed) { - resolve(); - return; - } const timeout = setTimeout(() => { if (child.exitCode == null) { child.kill('SIGKILL'); @@ -158,6 +164,34 @@ function stopChild(child) { }); } +function taskkillProcessTree(pid) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(finish, 5_000); + timeout.unref?.(); + try { + const taskkill = spawn( + path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), + ['/T', '/F', '/PID', String(pid)], + { + stdio: 'ignore', + windowsHide: true, + } + ); + taskkill.once('error', finish); + taskkill.once('close', finish); + } catch { + finish(); + } + }); +} + function allocateLoopbackPort() { return new Promise((resolve, reject) => { const server = net.createServer(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 833cb9a5..f1f1946c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -152,6 +152,10 @@ import * as path from 'path'; import pidusage from 'pidusage'; import * as readline from 'readline'; +// pidusage's Windows gwmi fallback needs a non-zero cache window to finish its +// initial two-sample pass. maxage: 0 can recurse forever on Windows. +const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; + import { ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS, type AnthropicTeamApiKeyHelperMaterial, @@ -15298,7 +15302,7 @@ export class TeamProvisioningService { let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined; if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) { try { - const refreshedStat = await pidusage(rssPid, { maxage: 0 }); + const refreshedStat = await pidusage(rssPid, RUNTIME_PIDUSAGE_OPTIONS); if (Number.isFinite(refreshedStat.memory) && refreshedStat.memory >= 0) { rssBytesByPid.set(rssPid, refreshedStat.memory); rssBytes = refreshedStat.memory; @@ -25558,7 +25562,7 @@ export class TeamProvisioningService { } const rssBytesByPid = new Map(); - const options = { maxage: 0 }; + const options = RUNTIME_PIDUSAGE_OPTIONS; try { const statsByPid = await pidusage(uniquePids, options); for (const [rawPid, stat] of Object.entries(statsByPid)) { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index bd26e321..b8c8730b 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -176,6 +176,9 @@ import { } from '@features/tmux-installer/main'; import pidusage from 'pidusage'; +const EXPECTED_RUNTIME_PIDUSAGE_OPTIONS = + process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; + function allowConsoleLogs() { vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -2490,7 +2493,7 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenCalledWith([111, 222], { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([111, 222], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members['team-lead']).toMatchObject({ pid: 111, rssBytes: 123_000_000, @@ -2630,9 +2633,9 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenNthCalledWith(1, [111, 222], { maxage: 0 }); - expect(pidusage).toHaveBeenNthCalledWith(2, 111, { maxage: 0 }); - expect(pidusage).toHaveBeenNthCalledWith(3, 222, { maxage: 0 }); + expect(pidusage).toHaveBeenNthCalledWith(1, [111, 222], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); + expect(pidusage).toHaveBeenNthCalledWith(2, 111, EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); + expect(pidusage).toHaveBeenNthCalledWith(3, 222, EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members['team-lead']?.rssBytes).toBe(123_000_000); expect(snapshot.members.alice?.rssBytes).toBe(456_000_000); }); @@ -2744,7 +2747,7 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team'); - expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([111, 333], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members.alice).toMatchObject({ alive: true, providerId: 'anthropic', @@ -3256,8 +3259,8 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 }); - expect(pidusage).toHaveBeenCalledWith(333, { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([111, 333], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); + expect(pidusage).toHaveBeenCalledWith(333, EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', alive: false, @@ -3332,7 +3335,7 @@ describe('TeamProvisioningService', () => { const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); - expect(pidusage).toHaveBeenCalledWith([333], { maxage: 0 }); + expect(pidusage).toHaveBeenCalledWith([333], EXPECTED_RUNTIME_PIDUSAGE_OPTIONS); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', alive: false, From 8fde8cefbfe91a0ece72d5d1e3cd5b01b62415c4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 12:45:33 +0300 Subject: [PATCH 05/11] fix(opencode): harden Windows live preflight cleanup --- scripts/lib/opencode-live-preflight.mjs | 90 ++++++++--- .../services/team/TeamProvisioningService.ts | 8 +- .../team/TeamProvisioningService.test.ts | 6 +- test/scripts/opencodeLivePreflight.test.ts | 147 ++++++++++++++++++ 4 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 test/scripts/opencodeLivePreflight.test.ts diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs index a765c3ac..1fc7d734 100644 --- a/scripts/lib/opencode-live-preflight.mjs +++ b/scripts/lib/opencode-live-preflight.mjs @@ -4,6 +4,10 @@ import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; +const CHILD_CLOSE_GRACE_MS = 3_000; +const CHILD_FORCE_CLOSE_GRACE_MS = 1_000; +const TASKKILL_TIMEOUT_MS = 5_000; + export async function preflightOpenCodeLiveEnvironment(input) { const repoRoot = input.repoRoot; const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode'; @@ -139,44 +143,54 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { } } -async function stopChild(child) { - if (child.exitCode != null || child.killed) { +async function stopChild(child, options = {}) { + const platform = options.platform ?? process.platform; + const killProcessTree = options.killProcessTree ?? taskkillProcessTree; + const closeGraceMs = options.closeGraceMs ?? CHILD_CLOSE_GRACE_MS; + const forceCloseGraceMs = options.forceCloseGraceMs ?? CHILD_FORCE_CLOSE_GRACE_MS; + + if (hasChildExited(child)) { return; } - if (process.platform === 'win32' && child.pid) { - await taskkillProcessTree(child.pid); + if (platform === 'win32' && child.pid) { + await killProcessTree(child.pid); + } else if (!child.killed) { + sendChildSignal(child, 'SIGTERM'); + } + + if (await waitForChildClose(child, closeGraceMs)) { return; } - return new Promise((resolve) => { - const timeout = setTimeout(() => { - if (child.exitCode == null) { - child.kill('SIGKILL'); - } - resolve(); - }, 3_000); - child.once('close', () => { - clearTimeout(timeout); - resolve(); - }); - child.kill('SIGTERM'); - }); + if (!hasChildExited(child)) { + sendChildSignal(child, 'SIGKILL'); + if (!(await waitForChildClose(child, forceCloseGraceMs))) { + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref?.(); + } + } } function taskkillProcessTree(pid) { return new Promise((resolve) => { let done = false; + let taskkill = null; const finish = () => { if (done) return; done = true; clearTimeout(timeout); resolve(); }; - const timeout = setTimeout(finish, 5_000); - timeout.unref?.(); + const timeout = setTimeout(() => { + if (taskkill) { + sendChildSignal(taskkill, 'SIGTERM'); + } + finish(); + }, TASKKILL_TIMEOUT_MS); try { - const taskkill = spawn( + taskkill = spawn( path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/T', '/F', '/PID', String(pid)], { @@ -184,6 +198,7 @@ function taskkillProcessTree(pid) { windowsHide: true, } ); + taskkill.unref?.(); taskkill.once('error', finish); taskkill.once('close', finish); } catch { @@ -192,6 +207,36 @@ function taskkillProcessTree(pid) { }); } +function waitForChildClose(child, timeoutMs) { + if (hasChildExited(child)) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + let done = false; + const finish = (closed) => { + if (done) return; + done = true; + clearTimeout(timeout); + resolve(closed); + }; + const timeout = setTimeout(() => finish(false), timeoutMs); + child.once('close', () => finish(true)); + }); +} + +function hasChildExited(child) { + return child.exitCode != null || child.signalCode != null; +} + +function sendChildSignal(child, signal) { + try { + child.kill(signal); + } catch { + // Process may already be gone between liveness checks and the kill call. + } +} + function allocateLoopbackPort() { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -224,3 +269,8 @@ function skip(reason) { function compactOutput(value) { return value.replace(/\s+/g, ' ').trim().slice(0, 1_200); } + +export const __opencodeLivePreflightTestHooks = { + stopChild, + taskkillProcessTree, +}; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f1f1946c..516f8047 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -152,10 +152,6 @@ import * as path from 'path'; import pidusage from 'pidusage'; import * as readline from 'readline'; -// pidusage's Windows gwmi fallback needs a non-zero cache window to finish its -// initial two-sample pass. maxage: 0 can recurse forever on Windows. -const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; - import { ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS, type AnthropicTeamApiKeyHelperMaterial, @@ -576,6 +572,10 @@ import type { ToolCallMeta, } from '@shared/types'; +// pidusage's Windows gwmi fallback needs a non-zero cache window to finish its +// initial two-sample pass. maxage: 0 can recurse forever on Windows. +const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; + const logger = createLogger('Service:TeamProvisioning'); const PREFLIGHT_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'claude-team-preflight-debug.log'); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index b8c8730b..80e3d67e 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -15453,9 +15453,9 @@ describe('TeamProvisioningService', () => { }); expect(spawnCli).toHaveBeenCalled(); - expect(progressUpdates[0]?.warnings).toEqual(expect.arrayContaining([ - expect.stringContaining('9 primary teammates'), - ])); + expect(progressUpdates[0]?.warnings).toEqual( + expect.arrayContaining([expect.stringContaining('9 primary teammates')]) + ); expect(progressUpdates[0]?.warnings?.join('\n')).toContain('Launches above 8 teammates'); }); diff --git a/test/scripts/opencodeLivePreflight.test.ts b/test/scripts/opencodeLivePreflight.test.ts new file mode 100644 index 00000000..0176d371 --- /dev/null +++ b/test/scripts/opencodeLivePreflight.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment node + +import { EventEmitter } from 'events'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +interface StopChildOptions { + platform?: string; + killProcessTree?: (pid: number) => Promise; + closeGraceMs?: number; + forceCloseGraceMs?: number; +} + +interface OpenCodeLivePreflightTestHooks { + __opencodeLivePreflightTestHooks: { + stopChild(child: FakeChild, options?: StopChildOptions): Promise; + taskkillProcessTree(pid: number): Promise; + }; +} + +const runOnPosix = process.platform === 'win32' ? it.skip : it; + +describe('opencode live preflight cleanup', () => { + let tempDir = ''; + const originalSystemRoot = process.env.SystemRoot; + const originalTaskkillArgsPath = process.env.FAKE_TASKKILL_ARGS_PATH; + + afterEach(async () => { + restoreEnvValue('SystemRoot', originalSystemRoot); + restoreEnvValue('FAKE_TASKKILL_ARGS_PATH', originalTaskkillArgsPath); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + }); + + it('waits for child close after Windows process-tree cleanup', async () => { + const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; + const child = new FakeChild({ pid: 1234 }); + const killProcessTree = vi.fn(() => { + child.signalCode = 'SIGTERM'; + child.emit('close'); + return Promise.resolve(); + }); + + await stopChild(child, { + closeGraceMs: 5, + forceCloseGraceMs: 5, + killProcessTree, + platform: 'win32', + }); + + expect(killProcessTree).toHaveBeenCalledWith(1234); + expect(child.kill).not.toHaveBeenCalled(); + expect(child.stdout.destroy).not.toHaveBeenCalled(); + expect(child.unref).not.toHaveBeenCalled(); + }); + + it('detaches pipes when Windows process-tree cleanup and direct kill both fail to close', async () => { + const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; + const child = new FakeChild({ pid: 5678 }); + const killProcessTree = vi.fn(() => Promise.resolve()); + + await stopChild(child, { + closeGraceMs: 1, + forceCloseGraceMs: 1, + killProcessTree, + platform: 'win32', + }); + + expect(killProcessTree).toHaveBeenCalledWith(5678); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(child.stdout.destroy).toHaveBeenCalled(); + expect(child.stderr.destroy).toHaveBeenCalled(); + expect(child.unref).toHaveBeenCalled(); + }); + + runOnPosix('invokes taskkill.exe with process-tree flags', async () => { + const { taskkillProcessTree } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-taskkill-test-')); + const system32Dir = path.join(tempDir, 'System32'); + const taskkillArgsPath = path.join(tempDir, 'taskkill-args.txt'); + + await fs.mkdir(system32Dir, { recursive: true }); + await writeExecutable(path.join(system32Dir, 'taskkill.exe'), fakeTaskkillScript()); + process.env.SystemRoot = tempDir; + process.env.FAKE_TASKKILL_ARGS_PATH = taskkillArgsPath; + + await taskkillProcessTree(4242); + + await expect(fs.readFile(taskkillArgsPath, 'utf8')).resolves.toBe('/T /F /PID 4242\n'); + }); +}); + +class FakeChild extends EventEmitter { + readonly kill = vi.fn(); + readonly stderr = { destroy: vi.fn() }; + readonly stdout = { destroy: vi.fn() }; + readonly unref = vi.fn(); + exitCode: number | null = null; + killed = false; + pid: number; + signalCode: string | null = null; + + constructor(input: { pid: number }) { + super(); + this.pid = input.pid; + this.kill.mockImplementation((signal: string) => { + this.killed = true; + return signal === 'SIGKILL'; + }); + } +} + +async function loadTestHooks(): Promise { + const moduleUrl = pathToFileURL( + path.join(process.cwd(), 'scripts/lib/opencode-live-preflight.mjs') + ).href; + return (await import(`${moduleUrl}?t=${Date.now()}`)) as OpenCodeLivePreflightTestHooks; +} + +async function writeExecutable(filePath: string, content: string): Promise { + await fs.writeFile(filePath, content, 'utf8'); + // eslint-disable-next-line sonarjs/file-permissions -- The taskkill fixture must be executable for child_process.spawn. + await fs.chmod(filePath, 0o755); +} + +function fakeTaskkillScript(): string { + return `#!/usr/bin/env node +const fs = require('node:fs'); + +fs.writeFileSync(process.env.FAKE_TASKKILL_ARGS_PATH, process.argv.slice(2).join(' ') + '\\n'); +process.exit(0); +`; +} + +function restoreEnvValue(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} From e6b9490c449a10d8c146e9eba59fb0a1b17d9bd6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 13:09:33 +0300 Subject: [PATCH 06/11] fix(opencode): widen Windows pidusage cache window --- src/main/services/team/TeamProvisioningService.ts | 7 ++++--- test/main/services/team/TeamProvisioningService.test.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 516f8047..d55d0e91 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -572,9 +572,10 @@ import type { ToolCallMeta, } from '@shared/types'; -// pidusage's Windows gwmi fallback needs a non-zero cache window to finish its -// initial two-sample pass. maxage: 0 can recurse forever on Windows. -const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; +// pidusage's Windows wmic/gwmi fallback needs a non-zero cache window to finish +// its initial two-sample pass. Keep this above slow PowerShell startup time, or +// the first sample can expire before the recursive second read and loop again. +const RUNTIME_PIDUSAGE_OPTIONS = process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 }; const logger = createLogger('Service:TeamProvisioning'); const PREFLIGHT_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'claude-team-preflight-debug.log'); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 80e3d67e..ad7b7129 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -177,7 +177,7 @@ import { import pidusage from 'pidusage'; const EXPECTED_RUNTIME_PIDUSAGE_OPTIONS = - process.platform === 'win32' ? { maxage: 1_000 } : { maxage: 0 }; + process.platform === 'win32' ? { maxage: 10_000 } : { maxage: 0 }; function allowConsoleLogs() { vi.spyOn(console, 'error').mockImplementation(() => {}); From ddfa4cc59d5d963480431eb964a97d67503da67a Mon Sep 17 00:00:00 2001 From: infiniti <52129260+developerInfiniti@users.noreply.github.com> Date: Sat, 16 May 2026 15:04:32 +0300 Subject: [PATCH 07/11] fix(windows): harden packaging and codex smoke --- scripts/electron-builder/afterPack.cjs | 107 +++++++++++++++++- scripts/electron-builder/verifyBundle.cjs | 26 ++++- scripts/smoke/agent-attachments-smoke.mjs | 86 +++++++++++++- .../build/electronBuilderAfterPack.test.ts | 72 +++++++++++- 4 files changed, 281 insertions(+), 10 deletions(-) diff --git a/scripts/electron-builder/afterPack.cjs b/scripts/electron-builder/afterPack.cjs index c4ca053c..3ddcff23 100644 --- a/scripts/electron-builder/afterPack.cjs +++ b/scripts/electron-builder/afterPack.cjs @@ -159,6 +159,93 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) { return removedPaths; } +function findNodeModulesSequence(segments, sequence) { + for (let index = 0; index <= segments.length - sequence.length; index += 1) { + let matches = true; + for (let offset = 0; offset < sequence.length; offset += 1) { + if (segments[index + offset] !== sequence[offset]) { + matches = false; + break; + } + } + if (matches) { + return index; + } + } + return -1; +} + +function getKnownPrunableNativeArtifactRoot(appOutDir, filePath, targetPlatform, targetArch) { + if (targetPlatform !== 'win32') { + return null; + } + + const relativePath = path.relative(appOutDir, filePath); + const segments = relativePath.split(path.sep); + + const conptyIndex = findNodeModulesSequence(segments, [ + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + ]); + const conptyArchIndex = conptyIndex + 5; + const conptyArchDir = conptyIndex >= 0 ? segments[conptyArchIndex] : null; + if (conptyArchDir?.startsWith('win10-') && conptyArchDir !== `win10-${targetArch}`) { + return path.join(appOutDir, ...segments.slice(0, conptyArchIndex + 1)); + } + + return null; +} + +function isKnownAllowedNativeMismatch(relativePath, format, archs, targetPlatform) { + const normalizedPath = relativePath.split(path.sep).join('/'); + const ssh2PageantPath = 'node_modules/ssh2/util/pagent.exe'; + + return ( + targetPlatform === 'win32' && + (normalizedPath === ssh2PageantPath || normalizedPath.endsWith(`/${ssh2PageantPath}`)) && + format === 'pe' && + archs.size === 1 && + archs.has('ia32') + ); +} + +async function pruneKnownIncompatibleNativeArtifacts(appOutDir, targetPlatform, targetArch) { + const files = await walkFiles(appOutDir); + const rootsToRemove = new Set(); + + for (const filePath of files) { + const rootToRemove = getKnownPrunableNativeArtifactRoot( + appOutDir, + filePath, + targetPlatform, + targetArch + ); + if (!rootToRemove) { + continue; + } + + const metadata = await detectBinaryMetadata(filePath); + if (!metadata) { + continue; + } + + if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) { + continue; + } + + rootsToRemove.add(rootToRemove); + } + + const removedPaths = []; + for (const absolutePath of rootsToRemove) { + await fs.promises.rm(absolutePath, { recursive: true, force: true }); + removedPaths.push(absolutePath); + } + return removedPaths; +} + function mapMachOCpuType(cpuType) { switch (cpuType >>> 0) { case 0x00000007: @@ -335,6 +422,7 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) { const files = await walkFiles(appOutDir); for (const filePath of files) { + const relativePath = path.relative(appOutDir, filePath); const metadata = await detectBinaryMetadata(filePath); if (!metadata) { continue; @@ -344,8 +432,14 @@ async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) { continue; } + if ( + isKnownAllowedNativeMismatch(relativePath, metadata.format, metadata.archs, targetPlatform) + ) { + continue; + } + mismatches.push({ - path: path.relative(appOutDir, filePath), + path: relativePath, format: metadata.format, archs: [...metadata.archs].sort(), }); @@ -358,7 +452,14 @@ async function afterPack(context) { const targetPlatform = context.electronPlatformName; const targetArch = getArchLabel(context.arch); - const removedPaths = await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch); + const removedPaths = [ + ...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)), + ...(await pruneKnownIncompatibleNativeArtifacts( + context.appOutDir, + targetPlatform, + targetArch + )), + ]; const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch); if (mismatches.length > 0) { @@ -383,9 +484,11 @@ module.exports._internal = { detectBinaryMetadata, getArchLabel, isBinaryCompatible, + isKnownAllowedNativeMismatch, parseElf, parseMachO, parsePortableExecutable, + pruneKnownIncompatibleNativeArtifacts, pruneNodePtyArtifacts, validateNativeBinaries, walkFiles, diff --git a/scripts/electron-builder/verifyBundle.cjs b/scripts/electron-builder/verifyBundle.cjs index 29ec81db..c4e68520 100644 --- a/scripts/electron-builder/verifyBundle.cjs +++ b/scripts/electron-builder/verifyBundle.cjs @@ -4,6 +4,18 @@ const afterPackModule = require('./afterPack.cjs'); const { validateNativeBinaries } = afterPackModule._internal; +function isAllowedPostPackMismatch(mismatch, platform, arch) { + const relativePath = mismatch.path.split(path.sep).join('/'); + return ( + platform === 'win32' && + arch === 'x64' && + relativePath === 'resources/elevate.exe' && + mismatch.format === 'pe' && + mismatch.archs.length === 1 && + mismatch.archs[0] === 'ia32' + ); +} + async function main() { const [bundlePathArg, platform, arch] = process.argv.slice(2); @@ -14,16 +26,22 @@ async function main() { const bundlePath = path.resolve(bundlePathArg); const mismatches = await validateNativeBinaries(bundlePath, platform, arch); + const blockingMismatches = mismatches.filter( + (mismatch) => !isAllowedPostPackMismatch(mismatch, platform, arch) + ); - if (mismatches.length === 0) { - console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}`); + if (blockingMismatches.length === 0) { + const allowedCount = mismatches.length - blockingMismatches.length; + const suffix = + allowedCount > 0 ? ` (${allowedCount} allowed post-pack helper mismatch ignored)` : ''; + console.log(`[verifyBundle] OK ${platform}-${arch}: ${bundlePath}${suffix}`); return; } console.error( - `[verifyBundle] Found ${mismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}` + `[verifyBundle] Found ${blockingMismatches.length} incompatible native binaries in ${platform}-${arch}: ${bundlePath}` ); - for (const mismatch of mismatches.slice(0, 50)) { + for (const mismatch of blockingMismatches.slice(0, 50)) { console.error(`- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`); } process.exit(1); diff --git a/scripts/smoke/agent-attachments-smoke.mjs b/scripts/smoke/agent-attachments-smoke.mjs index dc83b686..13f323d2 100644 --- a/scripts/smoke/agent-attachments-smoke.mjs +++ b/scripts/smoke/agent-attachments-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -326,12 +327,93 @@ function parseArgs(argv) { return { all, jsonPath, list, selected }; } +function hasPathSeparator(value) { + return value.includes('/') || value.includes('\\'); +} + +function resolveWindowsSpawnBinary(binary) { + if (process.platform !== 'win32') { + return binary; + } + + if (hasPathSeparator(binary)) { + if (!path.extname(binary) && existsSync(`${binary}.cmd`)) { + return `${binary}.cmd`; + } + return binary; + } + + const whereResult = spawnSync('where.exe', [binary], { + encoding: 'utf8', + windowsHide: true, + }); + if (whereResult.status !== 0 || !whereResult.stdout) { + return binary; + } + + const candidates = whereResult.stdout + .split(/\r?\n/) + .map((candidate) => candidate.trim()) + .filter(Boolean); + const extensionlessShim = candidates.find( + (candidate) => !path.extname(candidate) && existsSync(`${candidate}.cmd`) + ); + if (extensionlessShim) { + return `${extensionlessShim}.cmd`; + } + return ( + candidates.find((candidate) => /\.exe$/i.test(candidate)) ?? + candidates.find((candidate) => /\.(?:cmd|bat)$/i.test(candidate)) ?? + candidates[0] ?? + binary + ); +} + +function quoteWindowsCmdArg(value) { + const text = String(value); + if (text.length === 0) { + return '""'; + } + if (!/[ \t\r\n"&|<>^()%!]/.test(text)) { + return text; + } + return `"${text.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`; +} + +function buildSpawnInvocation(command) { + if (process.platform !== 'win32') { + return { + bin: command.bin, + args: command.args, + options: { windowsHide: true }, + }; + } + + const resolvedBin = resolveWindowsSpawnBinary(command.bin); + if (/\.(?:cmd|bat)$/i.test(resolvedBin)) { + const commandLine = [resolvedBin, ...command.args].map(quoteWindowsCmdArg).join(' '); + return { + bin: process.env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', commandLine], + options: { windowsHide: true, windowsVerbatimArguments: true }, + }; + } + + return { + bin: resolvedBin, + args: command.args, + options: { windowsHide: true }, + }; +} + function runCommand(command) { return new Promise((resolve) => { - const child = spawn(command.bin, command.args, { + const spawnInvocation = buildSpawnInvocation(command); + const child = spawn(spawnInvocation.bin, spawnInvocation.args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, cwd: command.cwd, + ...spawnInvocation.options, }); let stdout = ''; let stderr = ''; diff --git a/test/main/build/electronBuilderAfterPack.test.ts b/test/main/build/electronBuilderAfterPack.test.ts index 7b8428e4..8835dbfc 100644 --- a/test/main/build/electronBuilderAfterPack.test.ts +++ b/test/main/build/electronBuilderAfterPack.test.ts @@ -45,8 +45,8 @@ function createElfBuffer(arch: 'arm64' | 'x64'): Buffer { return buffer; } -function createPortableExecutableBuffer(arch: 'arm64' | 'x64'): Buffer { - const machine = arch === 'arm64' ? 0xaa64 : 0x8664; +function createPortableExecutableBuffer(arch: 'arm64' | 'ia32' | 'x64'): Buffer { + const machine = arch === 'arm64' ? 0xaa64 : arch === 'ia32' ? 0x014c : 0x8664; const buffer = Buffer.alloc(256); buffer[0] = 0x4d; buffer[1] = 0x5a; @@ -224,4 +224,72 @@ describe('electron-builder afterPack', () => { ) ).toBe(false); }); + + it('accepts a clean x64 Windows bundle with optional standalone helper binaries', async () => { + const tempDir = createTempDir(); + tempDirs.push(tempDir); + + writeFile(path.join(tempDir, 'Agent Teams UI.exe'), createPortableExecutableBuffer('x64')); + writeFile( + path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'node-pty', + 'build', + 'Release', + 'pty.node' + ), + createPortableExecutableBuffer('x64') + ); + const pageantPath = path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'ssh2', + 'util', + 'pagent.exe' + ); + const armConptyDir = path.join( + tempDir, + 'resources', + 'app.asar.unpacked', + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + '1.23.251008001', + 'win10-arm64' + ); + writeFile(pageantPath, createPortableExecutableBuffer('ia32')); + writeFile(path.join(armConptyDir, 'conpty.dll'), createPortableExecutableBuffer('arm64')); + writeFile(path.join(armConptyDir, 'OpenConsole.exe'), createPortableExecutableBuffer('arm64')); + + await afterPackModule({ + appOutDir: tempDir, + electronPlatformName: 'win32', + arch: 1, + }); + + expect(fs.existsSync(pageantPath)).toBe(true); + expect(fs.existsSync(armConptyDir)).toBe(false); + }); + + it('still reports unrelated ia32 Windows binaries in an x64 bundle', async () => { + const tempDir = createTempDir(); + tempDirs.push(tempDir); + + const badBinaryPath = path.join(tempDir, 'resources', 'app.asar.unpacked', 'bad-helper.exe'); + writeFile(badBinaryPath, createPortableExecutableBuffer('ia32')); + + await expect(validateNativeBinaries(tempDir, 'win32', 'x64')).resolves.toEqual([ + { + path: path.join('resources', 'app.asar.unpacked', 'bad-helper.exe'), + format: 'pe', + archs: ['ia32'], + }, + ]); + }); }); From 1cd77fbdd1f8750545745ba1976f54777d010de1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 12:35:06 +0300 Subject: [PATCH 08/11] fix(member-work-sync): recover stale native in-progress tasks --- .../MemberWorkSyncNudgeActivationPolicy.ts | 137 ++++++- ...emberWorkSyncNudgeActivationPolicy.test.ts | 363 ++++++++++++++++++ .../main/createMemberWorkSyncFeature.test.ts | 187 +++++++++ 3 files changed, 686 insertions(+), 1 deletion(-) diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index 49439b5c..24d5b21b 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -7,16 +7,24 @@ import { type MemberWorkSyncTargetedRecoveryReason, } from './MemberWorkSyncTargetedRecoveryPolicy'; -import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts'; +import type { + MemberWorkSyncMetricEvent, + MemberWorkSyncStatus, + MemberWorkSyncTeamMetrics, +} from '../../contracts'; export type MemberWorkSyncNudgeActivationReason = | 'shadow_ready' | MemberWorkSyncTargetedRecoveryReason | 'review_pickup_required' + | 'native_stale_in_progress' | 'status_not_nudgeable' | 'blocking_metrics' | 'phase2_not_ready'; +const NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS = 6 * 60_000; +const NATIVE_STALE_IN_PROGRESS_PROVIDERS = new Set(['anthropic', 'codex', 'gemini']); + export interface MemberWorkSyncNudgeActivationDecision { active: boolean; reason: MemberWorkSyncNudgeActivationReason; @@ -32,6 +40,129 @@ function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean { return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason)); } +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function isLeadLikeMemberName(memberName: string): boolean { + const normalized = normalizeMemberName(memberName).replace(/[\s_]+/g, '-'); + return ( + normalized === 'lead' || + normalized === 'team-lead' || + normalized === 'teamlead' || + normalized === 'team-leader' + ); +} + +function parseTime(value: string | undefined): number | null { + if (!value) { + return null; + } + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; +} + +function eventsForMember( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): MemberWorkSyncMetricEvent[] { + const memberName = normalizeMemberName(status.memberName); + return metrics.recentEvents + .filter((event) => normalizeMemberName(event.memberName) === memberName) + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); +} + +function hasAcceptedReportForCurrentFingerprint( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics +): boolean { + return eventsForMember(status, metrics).some( + (event) => + event.kind === 'report_accepted' && event.agendaFingerprint === status.agenda.fingerprint + ); +} + +function isDifferentFingerprintBoundary( + event: MemberWorkSyncMetricEvent, + currentFingerprint: string +): boolean { + if (event.agendaFingerprint !== currentFingerprint) { + return true; + } + return ( + event.kind === 'fingerprint_changed' && + event.previousFingerprint !== undefined && + event.previousFingerprint !== currentFingerprint + ); +} + +function getCurrentFingerprintStableSinceMs( + status: MemberWorkSyncStatus, + metrics: MemberWorkSyncTeamMetrics, + nowMs: number +): number | null { + const currentFingerprint = status.agenda.fingerprint; + const memberEvents = eventsForMember(status, metrics).filter((event) => { + const recordedAt = parseTime(event.recordedAt); + return recordedAt != null && recordedAt <= nowMs; + }); + let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY; + for (const event of memberEvents) { + const recordedAt = parseTime(event.recordedAt); + if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) { + latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt); + } + } + + const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => { + const recordedAt = parseTime(event.recordedAt); + return event.kind === 'status_evaluated' && + event.state === 'needs_sync' && + event.agendaFingerprint === currentFingerprint && + recordedAt != null && + recordedAt >= latestDifferentFingerprintMs + ? [recordedAt] + : []; + }); + + return currentNeedsSyncEventTimes.length > 0 ? Math.min(...currentNeedsSyncEventTimes) : null; +} + +function isNativeStaleInProgressCandidate(input: { + status: MemberWorkSyncStatus; + metrics: MemberWorkSyncTeamMetrics; +}): boolean { + const { status, metrics } = input; + if ( + status.state !== 'needs_sync' || + status.shadow?.wouldNudge !== true || + !status.diagnostics.includes('no_current_report') || + !status.providerId || + !NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) || + isLeadLikeMemberName(status.memberName) || + status.agenda.items.length !== 1 || + hasAcceptedReportForCurrentFingerprint(status, metrics) + ) { + return false; + } + + const [item] = status.agenda.items; + if ( + item.kind !== 'work' || + item.reason !== 'owned_in_progress_task' || + item.evidence.status !== 'in_progress' + ) { + return false; + } + + const nowMs = parseTime(metrics.generatedAt) ?? parseTime(status.evaluatedAt); + if (nowMs == null) { + return false; + } + const stableSinceMs = getCurrentFingerprintStableSinceMs(status, metrics, nowMs); + return stableSinceMs != null && nowMs - stableSinceMs >= NATIVE_STALE_IN_PROGRESS_MIN_AGE_MS; +} + function isReviewPickupRequiredCandidate(status: MemberWorkSyncStatus): boolean { return ( status.state === 'needs_sync' && @@ -61,6 +192,10 @@ export function decideMemberWorkSyncNudgeActivation(input: { return { active: true, reason: targetedRecovery.reason }; } + if (isNativeStaleInProgressCandidate(input)) { + return { active: true, reason: 'native_stale_in_progress' }; + } + if (hasBlockingMetrics(input.metrics)) { return { active: false, reason: 'blocking_metrics' }; } diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts index 74883e6a..86e3cad2 100644 --- a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -86,6 +86,62 @@ function metrics(overrides: Partial = {}): MemberWork }; } +function nativeStaleInProgressStatus( + overrides: Partial = {} +): MemberWorkSyncStatus { + const base = status({ + providerId: 'codex', + diagnostics: ['no_current_report'], + agenda: { + ...status().agenda, + fingerprint: 'agenda:v1:native-stale', + items: [ + { + taskId: 'task-1', + displayId: '#1', + subject: 'Review landing', + kind: 'work', + assignee: 'alice', + priority: 'normal', + reason: 'owned_in_progress_task', + evidence: { + status: 'in_progress', + owner: 'alice', + }, + }, + ], + }, + }); + return { ...base, ...overrides }; +} + +function staleMetrics( + overrides: Partial = {} +): MemberWorkSyncTeamMetrics { + return metrics({ + generatedAt: '2026-05-06T00:06:00.000Z', + phase2Readiness: { + ...metrics().phase2Readiness, + state: 'blocked', + reasons: ['would_nudge_rate_high', 'fingerprint_churn_high'], + }, + recentEvents: [ + { + id: 'status-stale', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + ...overrides, + }); +} + describe('MemberWorkSyncNudgeActivationPolicy', () => { it('activates OpenCode targeted nudges while shadow data is still collecting', () => { expect( @@ -348,6 +404,313 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => { ).toEqual({ active: false, reason: 'blocking_metrics' }); }); + it('activates stale native single in-progress recovery despite blocking metrics', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + + it('does not activate stale native in-progress recovery before the quiet window elapses', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:05:59.000Z', + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery after an accepted report for the fingerprint', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + recentEvents: [ + ...staleMetrics().recentEvents, + { + id: 'report-accepted', + teamName: 'team-a', + memberName: 'alice', + kind: 'report_accepted', + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery when the accepted report state is still current', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + state: 'still_working', + report: { + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + memberName: 'alice', + teamName: 'team-a', + reportedAt: '2026-05-06T00:03:00.000Z', + accepted: true, + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'status_not_nudgeable' }); + }); + + it('resets the stale native in-progress quiet window after a fingerprint change', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:08:59.000Z', + recentEvents: [ + { + id: 'old-same-fingerprint', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'fingerprint-returned', + teamName: 'team-a', + memberName: 'alice', + kind: 'fingerprint_changed', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'current-after-change', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('activates stale native in-progress recovery after a returned fingerprint is stable long enough', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:09:00.000Z', + recentEvents: [ + { + id: 'old-same-fingerprint', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'fingerprint-returned', + teamName: 'team-a', + memberName: 'alice', + kind: 'fingerprint_changed', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'current-after-change', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + previousFingerprint: 'agenda:v1:other', + recordedAt: '2026-05-06T00:03:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + + it('does not activate stale native in-progress recovery from another member stale event', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + recentEvents: [ + { + id: 'other-member-status-stale', + teamName: 'team-a', + memberName: 'bob', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:00:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not use native stale recovery for OpenCode or lead members', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ providerId: 'opencode' }), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' }); + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ memberName: 'team-lead' }), + metrics: staleMetrics(), + }) + ).toEqual({ active: true, reason: 'lead_targeted_shadow_collecting' }); + }); + + it('does not activate stale native in-progress recovery for multiple or non-in-progress work items', () => { + const baseItem = nativeStaleInProgressStatus().agenda.items[0]!; + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [ + baseItem, + { + ...baseItem, + taskId: 'task-2', + }, + ], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [ + { + ...baseItem, + reason: 'owned_pending_task', + evidence: { + status: 'pending', + owner: 'alice', + }, + }, + ], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + }); + + it('does not activate stale native in-progress recovery for needsFix, review, blocked dependency, or clarification agenda items', () => { + const baseItem = nativeStaleInProgressStatus().agenda.items[0]!; + const cases = [ + { + ...baseItem, + evidence: { + status: 'needsFix', + owner: 'alice', + }, + }, + { + ...baseItem, + kind: 'review' as const, + priority: 'review_requested' as const, + reason: 'current_cycle_review_assigned', + evidence: { + status: 'completed', + owner: 'bob', + reviewer: 'alice', + reviewState: 'review', + reviewCycleId: 'evt-review-request', + reviewRequestEventId: 'evt-review-request', + reviewObligation: 'review_pickup_required' as const, + canBypassPhase2: true, + historyEventIds: ['evt-review-request'], + }, + }, + { + ...baseItem, + kind: 'blocked_dependency' as const, + priority: 'blocked' as const, + reason: 'blocked_by_incomplete_task', + evidence: { + status: 'in_progress', + owner: 'alice', + blockerTaskIds: ['task-blocker'], + }, + }, + { + ...baseItem, + kind: 'clarification' as const, + priority: 'needs_clarification' as const, + reason: 'lead_clarification_required', + evidence: { + status: 'in_progress', + owner: 'alice', + needsClarification: 'lead' as const, + }, + }, + ]; + + for (const item of cases) { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + agenda: { + ...nativeStaleInProgressStatus().agenda, + items: [item], + }, + }), + metrics: staleMetrics(), + }) + ).toEqual({ active: false, reason: 'blocking_metrics' }); + } + }); + it('keeps existing shadow_ready behavior for all providers', () => { expect( decideMemberWorkSyncNudgeActivation({ diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 7ce261db..c5ca1465 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -179,6 +179,69 @@ async function seedBlockingShadowCollectingMetrics(input: { ); } +async function seedNativeStaleInProgressBlockingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; + agendaFingerprint: string; +}): Promise { + const nowMs = Date.now(); + const staleObservedAt = new Date(nowMs - 6 * 60_000 - 1_000).toISOString(); + const metricsPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.promises.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + actionableCount: 1, + evaluatedAt: staleObservedAt, + providerId: 'codex', + }, + }, + recentEvents: [ + { + id: 'native-stale-status', + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + recordedAt: staleObservedAt, + actionableCount: 1, + providerId: 'codex', + }, + ...Array.from({ length: 12 }, (_, index) => ({ + id: `native-stale-would-nudge-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'would_nudge', + state: 'needs_sync', + agendaFingerprint: input.agendaFingerprint, + recordedAt: new Date(nowMs - 5 * 60_000 + index * 5_000).toISOString(), + actionableCount: 1, + providerId: 'codex', + })), + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + async function waitForAssertion(assertion: () => Promise | void): Promise { const deadline = Date.now() + 5_000; let lastError: unknown; @@ -1067,6 +1130,130 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('delivers native stale in-progress recovery nudges despite noisy global metrics', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-native-stale-in-progress'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Review landing', + status: 'in_progress', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + let agendaFingerprint = ''; + await waitForAssertion(async () => { + const status = await feature.getStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + providerId: 'codex', + diagnostics: expect.arrayContaining(['no_current_report']), + agenda: { + items: [ + expect.objectContaining({ + reason: 'owned_in_progress_task', + evidence: expect.objectContaining({ status: 'in_progress' }), + }), + ], + }, + }); + agendaFingerprint = status.agenda.fingerprint; + }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + + await seedNativeStaleInProgressBlockingMetrics({ + teamsBasePath, + teamName, + memberName, + agendaFingerprint, + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('Work sync check'); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'codex', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { + reasons: expect.arrayContaining(['would_nudge_rate_high']), + }, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_delivered"'); + expect(journal).toContain('"reason":"created"'); + } finally { + await feature.dispose(); + } + }); + it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); From 3552e423d9d89563e93423e8f8c53040aa46aa34 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 16 May 2026 12:37:17 +0300 Subject: [PATCH 09/11] feat: improve desktop workflows --- README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 31 ++ .../renderer/hooks/useGraphMessagesPanel.tsx | 86 +++++ .../renderer/ui/TeamGraphOverlay.tsx | 22 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 21 +- src/features/recent-projects/contracts/dto.ts | 3 + .../domain/models/RecentProjectAggregate.ts | 2 + .../domain/models/RecentProjectCandidate.ts | 2 + .../models/RecentProjectFilesystemState.ts | 1 + .../policies/mergeRecentProjectCandidates.ts | 9 +- .../DashboardRecentProjectsPresenter.ts | 1 + .../ClaudeRecentProjectsSourceAdapter.ts | 1 + .../CodexRecentProjectsSourceAdapter.ts | 11 +- ...xSessionFileRecentProjectsSourceAdapter.ts | 7 +- .../resolveProjectFilesystemState.ts | 21 ++ .../adapters/RecentProjectsSectionAdapter.ts | 2 + .../renderer/hooks/useOpenRecentProject.ts | 5 + .../renderer/ui/RecentProjectCard.tsx | 44 ++- src/main/index.ts | 31 +- src/main/services/discovery/ProjectScanner.ts | 25 ++ .../infrastructure/CliInstallerService.ts | 2 +- .../runtime/ClaudeMultimodelBridgeService.ts | 2 +- src/main/types/domain.ts | 6 + .../components/dashboard/CliStatusBanner.tsx | 2 +- .../components/layout/TeamTabSectionNav.tsx | 2 +- .../settings/sections/CliStatusSection.tsx | 2 +- .../components/team/TeamDetailView.tsx | 17 +- .../team/dialogs/CreateTeamDialog.tsx | 17 + .../team/dialogs/LaunchTeamDialog.tsx | 24 ++ .../team/dialogs/ProjectPathSelector.tsx | 62 ++- .../team/dialogs/projectPathOptions.ts | 24 +- .../team/dialogs/projectPathProjects.ts | 19 +- .../team/messages/MessageComposer.tsx | 55 +-- .../team/messages/MessagesPanel.tsx | 353 +++++++++++------- src/renderer/components/ui/combobox.tsx | 11 +- src/renderer/components/ui/dropdown-menu.tsx | 65 ++++ .../store/slices/cliInstallerSlice.ts | 4 +- src/renderer/types/teamMessagesPanelMode.ts | 2 +- .../mergeRecentProjectCandidates.test.ts | 36 ++ ...ionFileRecentProjectsSourceAdapter.test.ts | 36 ++ .../RecentProjectsSectionAdapter.test.ts | 5 +- .../discovery/ProjectScanner.cwdSplit.test.ts | 22 ++ .../CliInstallerService.test.ts | 2 +- .../ClaudeMultimodelBridgeService.test.ts | 2 +- .../cli/CliStatusVisibility.test.ts | 8 +- .../extensions/skills/SkillsPanel.test.ts | 2 +- .../team/dialogs/LaunchTeamDialog.test.ts | 6 + .../team/dialogs/projectPathOptions.test.ts | 23 ++ 49 files changed, 900 insertions(+), 239 deletions(-) create mode 100644 src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx create mode 100644 src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts create mode 100644 src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts create mode 100644 src/renderer/components/ui/dropdown-menu.tsx diff --git a/README.md b/README.md index c32e26f8..678494d5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (75+ LLM providers). Use the provider access you already have - subscriptions or API keys. Not just coding agents. + Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (200+ models). Use the provider access you already have - subscriptions or API keys. Not just coding agents.

image diff --git a/package.json b/package.json index 7eb4d707..76167bab 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bb07233..d8b7e807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': + specifier: 2.1.16 + version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-hover-card': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3191,6 +3194,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -13999,6 +14015,21 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 diff --git a/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx b/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx new file mode 100644 index 00000000..70ee7043 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphMessagesPanel.tsx @@ -0,0 +1,86 @@ +import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; + +import { MessagesPanel } from '@renderer/components/team/messages/MessagesPanel'; +import { + getTeamPendingRepliesState, + setTeamPendingRepliesState, +} from '@renderer/components/team/sidebar/teamSidebarUiState'; +import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { useShallow } from 'zustand/react/shallow'; + +interface UseGraphMessagesPanelInput { + teamName: string; + enabled?: boolean; + mountPoint?: Element | null; + onOpenMemberProfile: (memberName: string) => void; + onOpenTaskDetail: (taskId: string) => void; +} + +export function useGraphMessagesPanel({ + teamName, + enabled = true, + mountPoint, + onOpenMemberProfile, + onOpenTaskDetail, +}: UseGraphMessagesPanelInput): ReactElement { + const [pendingRepliesByMember, setPendingRepliesByMember] = useState(() => + getTeamPendingRepliesState(teamName) + ); + const { messagesPanelMode, setMessagesPanelMode, members, tasks, isTeamAlive } = useStore( + useShallow((state) => { + const teamData = selectTeamDataForName(state, teamName); + return { + messagesPanelMode: state.messagesPanelMode, + setMessagesPanelMode: state.setMessagesPanelMode, + members: selectResolvedMembersForTeamName(state, teamName), + tasks: teamData?.tasks ?? [], + isTeamAlive: teamData?.isAlive, + }; + }) + ); + const activeMembers = useMemo(() => members.filter((member) => !member.removedAt), [members]); + + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + + const handlePendingReplyChange = useCallback( + (updater: (prev: Record) => Record) => { + setPendingRepliesByMember(updater); + }, + [] + ); + + if ( + !enabled || + (messagesPanelMode !== 'floating-composer' && messagesPanelMode !== 'bottom-sheet') + ) { + return <>; + } + + return ( + onOpenMemberProfile(member.name)} + onTaskClick={(task) => onOpenTaskDetail(task.id)} + onTaskIdClick={onOpenTaskDetail} + /> + ); +} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index aebbfd07..40693276 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -3,12 +3,13 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; @@ -36,6 +37,7 @@ export interface TeamGraphOverlayProps { onPinAsTab?: () => void; sidebarVisible?: boolean; onToggleSidebar?: () => void; + messagesPanelEnabled?: boolean; onSendMessage?: (memberName: string) => void; onOpenTaskDetail?: (taskId: string) => void; onOpenMemberProfile?: ( @@ -53,6 +55,7 @@ export const TeamGraphOverlay = ({ onPinAsTab, sidebarVisible, onToggleSidebar, + messagesPanelEnabled = true, onSendMessage, onOpenTaskDetail, onOpenMemberProfile, @@ -67,8 +70,18 @@ export const TeamGraphOverlay = ({ const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; + const graphMessagesPanel = useGraphMessagesPanel({ + teamName, + enabled: messagesPanelEnabled, + mountPoint: messagesPanelMountPoint, + onOpenMemberProfile: (memberName) => onOpenMemberProfile?.(memberName), + onOpenTaskDetail: (taskId) => onOpenTaskDetail?.(taskId), + }); // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( @@ -242,6 +255,13 @@ export const TeamGraphOverlay = ({ /> )} /> + {messagesPanelEnabled ? ( +
+ ) : null} + {graphMessagesPanel} {createTaskDialog}
); diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index a0ab67f2..29356208 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -9,6 +9,7 @@ import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; +import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter'; import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'; @@ -54,6 +55,9 @@ export const TeamGraphTab = ({ const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } = useTeamGraphSurfaceActions(teamName); const [fullscreen, setFullscreen] = useState(false); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -129,9 +133,16 @@ export const TeamGraphTab = ({ [dispatchOpenProfile] ), }; + const graphMessagesPanel = useGraphMessagesPanel({ + teamName, + enabled: isActive && isPaneFocused && !fullscreen, + mountPoint: messagesPanelMountPoint, + onOpenMemberProfile: dispatchOpenProfile, + onOpenTaskDetail: dispatchOpenTask, + }); return ( -
+
{sidebarVisible ? (
+ {isActive && isPaneFocused && !fullscreen ? ( +
+ ) : null} + {graphMessagesPanel} {createTaskDialog} {fullscreen && ( @@ -273,6 +291,7 @@ export const TeamGraphTab = ({ onSendMessage={dispatchSendMessage} onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} + messagesPanelEnabled={isActive && isPaneFocused} /> )} diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts index bdb4eda0..1fa6e5c6 100644 --- a/src/features/recent-projects/contracts/dto.ts +++ b/src/features/recent-projects/contracts/dto.ts @@ -2,6 +2,8 @@ export type DashboardProviderId = 'anthropic' | 'codex' | 'gemini'; export type DashboardRecentProjectSource = 'claude' | 'codex' | 'mixed'; +export type DashboardRecentProjectFilesystemState = 'available' | 'deleted'; + export type DashboardRecentProjectOpenTarget = | { type: 'existing-worktree'; repositoryId: string; worktreeId: string } | { type: 'synthetic-path'; path: string }; @@ -16,6 +18,7 @@ export interface DashboardRecentProject { source: DashboardRecentProjectSource; openTarget: DashboardRecentProjectOpenTarget; primaryBranch?: string; + filesystemState?: DashboardRecentProjectFilesystemState; } export interface DashboardRecentProjectsPayload { diff --git a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts index 9c098a65..26402f5d 100644 --- a/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts +++ b/src/features/recent-projects/core/domain/models/RecentProjectAggregate.ts @@ -1,4 +1,5 @@ import type { ProviderId } from './ProviderId'; +import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState'; import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; export interface RecentProjectAggregate { @@ -11,4 +12,5 @@ export interface RecentProjectAggregate { source: 'claude' | 'codex' | 'mixed'; openTarget: RecentProjectOpenTarget; branchName?: string; + filesystemState: RecentProjectFilesystemState; } diff --git a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts index 2abc5315..17c389d2 100644 --- a/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts +++ b/src/features/recent-projects/core/domain/models/RecentProjectCandidate.ts @@ -1,4 +1,5 @@ import type { ProviderId } from './ProviderId'; +import type { RecentProjectFilesystemState } from './RecentProjectFilesystemState'; import type { RecentProjectOpenTarget } from './RecentProjectOpenTarget'; export interface RecentProjectCandidate { @@ -11,4 +12,5 @@ export interface RecentProjectCandidate { sourceKind: 'claude' | 'codex'; openTarget: RecentProjectOpenTarget; branchName?: string; + filesystemState?: RecentProjectFilesystemState; } diff --git a/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts new file mode 100644 index 00000000..22a3029a --- /dev/null +++ b/src/features/recent-projects/core/domain/models/RecentProjectFilesystemState.ts @@ -0,0 +1 @@ +export type RecentProjectFilesystemState = 'available' | 'deleted'; diff --git a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts index ce3be598..41efa95b 100644 --- a/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts +++ b/src/features/recent-projects/core/domain/policies/mergeRecentProjectCandidates.ts @@ -25,10 +25,14 @@ function uniqueProviders(providerIds: readonly ProviderId[]): ProviderId[] { function selectPreferredCandidate( candidates: readonly RecentProjectCandidate[] ): RecentProjectCandidate { - const existingWorktreeCandidates = candidates.filter( + const availableCandidates = candidates.filter( + (candidate) => candidate.filesystemState !== 'deleted' + ); + const candidatePool = availableCandidates.length > 0 ? availableCandidates : candidates; + const existingWorktreeCandidates = candidatePool.filter( (candidate) => candidate.openTarget.type === 'existing-worktree' ); - const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidates; + const pool = existingWorktreeCandidates.length > 0 ? existingWorktreeCandidates : candidatePool; return [...pool].sort((left, right) => { if (right.lastActivityAt !== left.lastActivityAt) { @@ -81,6 +85,7 @@ export function mergeRecentProjectCandidates( source: sourceKinds.size > 1 ? 'mixed' : sourceKinds.has('codex') ? 'codex' : 'claude', openTarget: preferred.openTarget, branchName: mergeBranchName(group), + filesystemState: preferred.filesystemState ?? 'available', }; }); diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts index c9a3e5fb..887ae7c8 100644 --- a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -20,6 +20,7 @@ export class DashboardRecentProjectsPresenter implements ListDashboardRecentProj source: aggregate.source, openTarget: aggregate.openTarget, primaryBranch: aggregate.branchName, + filesystemState: aggregate.filesystemState, }) ), }; diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts index d1d55a94..faefffb6 100644 --- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -44,6 +44,7 @@ function toCandidate(repo: RepositoryGroup): RecentProjectCandidate | null { worktreeId: preferredWorktree.id, }, branchName: preferredWorktree.gitBranch, + filesystemState: preferredWorktree.filesystemState ?? 'available', }; } diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index 75558354..8e13c8f2 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -1,4 +1,5 @@ import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; import path from 'path'; @@ -129,7 +130,9 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor ); const candidates = ( - await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread))) + await Promise.all( + interactiveThreads.map((thread) => this.#toCandidate(thread, activeContext.fsProvider)) + ) ).filter((candidate): candidate is RecentProjectCandidate => candidate !== null); if (!degraded) { @@ -299,7 +302,10 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor } } - async #toCandidate(thread: CodexThreadSummary): Promise { + async #toCandidate( + thread: CodexThreadSummary, + fsProvider?: ServiceContext['fsProvider'] + ): Promise { const cwd = thread.cwd?.trim(); if (!cwd || isEphemeralProjectPath(cwd)) { return null; @@ -321,6 +327,7 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor path: cwd, }, branchName: thread.gitInfo?.branch ?? undefined, + filesystemState: await resolveProjectFilesystemState(cwd, fsProvider), }; } } diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts index cec73762..a6140760 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { resolveProjectFilesystemState } from '@features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; @@ -225,7 +226,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec try { const snapshots = await this.#listRecentSessionSnapshots(); const candidates = await Promise.all( - snapshots.map((snapshot) => this.#toCandidate(snapshot)) + snapshots.map((snapshot) => this.#toCandidate(snapshot, activeContext.fsProvider)) ); const validCandidates = candidates.filter( @@ -303,7 +304,8 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec } async #toCandidate( - snapshot: CodexSessionProjectSnapshot + snapshot: CodexSessionProjectSnapshot, + fsProvider?: ServiceContext['fsProvider'] ): Promise { const identity = await this.deps.identityResolver.resolve(snapshot.cwd); const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd; @@ -321,6 +323,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec path: snapshot.cwd, }, branchName: snapshot.branchName, + filesystemState: await resolveProjectFilesystemState(snapshot.cwd, fsProvider), }; } } diff --git a/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts new file mode 100644 index 00000000..5f1a1d7f --- /dev/null +++ b/src/features/recent-projects/main/infrastructure/filesystem/resolveProjectFilesystemState.ts @@ -0,0 +1,21 @@ +import type { RecentProjectFilesystemState } from '../../../core/domain/models/RecentProjectFilesystemState'; +import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; + +export async function resolveProjectFilesystemState( + projectPath: string, + fsProvider?: Pick +): Promise { + if (!projectPath.trim()) { + return 'deleted'; + } + + if (!fsProvider) { + return 'available'; + } + + try { + return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted'; + } catch { + return 'deleted'; + } +} diff --git a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts index 58b0cd79..7568b4aa 100644 --- a/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts +++ b/src/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.ts @@ -15,6 +15,7 @@ export interface RecentProjectCardModel { lastActivityLabel: string; providerIds: DashboardRecentProject['providerIds']; primaryBranch?: string; + filesystemState?: DashboardRecentProject['filesystemState']; taskCounts?: TaskStatusCounts; tasksLoading: boolean; activeTeams?: TeamSummary[]; @@ -121,6 +122,7 @@ export function adaptRecentProjectsSection({ }), providerIds: sortDashboardProviderIds(project.providerIds), primaryBranch: project.primaryBranch, + filesystemState: project.filesystemState, taskCounts: sumTaskCounts(project, taskCountsByProject), tasksLoading, activeTeams: collectActiveTeams(project, activeTeamsByProject), diff --git a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts index fb7fd848..f01387f8 100644 --- a/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts +++ b/src/features/recent-projects/renderer/hooks/useOpenRecentProject.ts @@ -106,6 +106,11 @@ export function useOpenRecentProject(): { const openRecentProject = useCallback( async (project: DashboardRecentProject): Promise => { + if (project.filesystemState === 'deleted') { + logger.warn('Skipped deleted recent project path', { path: project.primaryPath }); + return; + } + try { await openTarget(project.openTarget, project.associatedPaths); recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]); diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx index ad187f70..cdc03227 100644 --- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx +++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx @@ -3,8 +3,9 @@ import { useMemo } from 'react'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { cn } from '@renderer/lib/utils'; import { projectColor } from '@renderer/utils/projectColor'; -import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react'; +import { FolderGit2, FolderOpen, FolderX, GitBranch, Terminal } from 'lucide-react'; import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter'; @@ -20,11 +21,17 @@ export const RecentProjectCard = ({ onOpenPath, }: Readonly): React.JSX.Element => { const color = useMemo(() => projectColor(card.name), [card.name]); + const isDeleted = card.filesystemState === 'deleted'; + const FolderIcon = isDeleted ? FolderX : FolderGit2; return (
@@ -3650,6 +3659,12 @@ export const TeamDetailView = memo(function TeamDetailView({ .getState() .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); }} + messagesPanelEnabled={ + (messagesPanelMode === 'floating-composer' || + messagesPanelMode === 'bottom-sheet') && + isThisTabActive && + isPaneFocused + } onSendMessage={(memberName) => { setSendDialogRecipient(memberName); setSendDialogDefaultText(undefined); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 3a22a60d..917c1ed9 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -671,6 +671,23 @@ export const CreateTeamDialog = ({ ); const lastPrepareRequestSignatureRef = useRef(null); + useEffect(() => { + const generation = ++prepareUnmountGenerationRef.current; + return () => { + // React StrictMode replays effect cleanup/setup in development; defer + // invalidation so the replay does not cancel the live prepare request. + queueMicrotask(() => { + if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) { + return; + } + cancelScheduledIdle(prepareIdleHandleRef.current); + prepareIdleHandleRef.current = null; + prepareRequestSeqRef.current += 1; + lastPrepareRequestSignatureRef.current = null; + }); + }; + }, []); + useEffect(() => { runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; }, [runtimeBackendSummaryByProvider]); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 838a497e..d16f3d91 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -79,6 +79,7 @@ import { CheckCircle2, ChevronDown, ChevronRight, + ExternalLink, Info, Loader2, X, @@ -230,6 +231,8 @@ export type LaunchTeamDialogProps = | LaunchDialogScheduleMode; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; +const ANTHROPIC_AGENT_SDK_CREDIT_ARTICLE_URL = + 'https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan'; // ============================================================================= // Helpers @@ -2730,6 +2733,27 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen This prompt will be passed to claude -p for one-shot execution

+ {selectedProviderId === 'anthropic' ? ( +
+ +

+ Starting June 15, 2026, Anthropic bills claude -p and Agent SDK + usage from the monthly Agent SDK credit, separate from interactive Claude Code + limits. The credit resets each billing cycle and unused credit does not roll + over.{' '} + + Read Anthropic article + + + . +

+
+ ) : null}
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index bb49c9ca..49d8af7c 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -7,7 +7,7 @@ import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; -import { Check, FolderOpen } from 'lucide-react'; +import { Check, FolderOpen, FolderX } from 'lucide-react'; import { buildProjectPathOptions, @@ -58,6 +58,10 @@ function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource | return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource; } +function isDeletedOption(option: ComboboxOption): boolean { + return (option.meta as ProjectPathOptionMeta | undefined)?.filesystemState === 'deleted'; +} + function getSourceLabel(source: DashboardRecentProjectSource): string { switch (source) { case 'claude': @@ -97,6 +101,16 @@ const ProjectSourceBadge = ({ ); }; +const ProjectDeletedBadge = (): React.JSX.Element => ( + + + Deleted + +); + export type CwdMode = 'project' | 'custom'; interface ProjectPathSelectorProps { @@ -178,28 +192,38 @@ export const ProjectPathSelector = ({ renderTriggerLabel={(option) => ( + {isDeletedOption(option) ? : null} {option.label} )} - renderOption={(option, isSelected, query) => ( - <> - - -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

+ renderOption={(option, isSelected, query) => { + const isDeleted = isDeletedOption(option); + return ( +
+ + + {isDeleted ? : null} +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
- - )} + ); + }} />
diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts index 8dd5af03..6cdae4b0 100644 --- a/src/renderer/components/team/dialogs/projectPathOptions.ts +++ b/src/renderer/components/team/dialogs/projectPathOptions.ts @@ -1,16 +1,21 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; -import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProjectFilesystemState, + DashboardRecentProjectSource, +} from '@features/recent-projects/contracts'; import type { ComboboxOption } from '@renderer/components/ui/combobox'; import type { Project } from '@shared/types'; export interface ProjectPathProject extends Project { discoverySource?: DashboardRecentProjectSource; + filesystemState?: DashboardRecentProjectFilesystemState; } export interface ProjectPathOptionMeta { discoverySource?: DashboardRecentProjectSource; + filesystemState?: DashboardRecentProjectFilesystemState; } function toProjectOption(project: ProjectPathProject): ComboboxOption { @@ -20,10 +25,19 @@ function toProjectOption(project: ProjectPathProject): ComboboxOption { description: project.path, }; - if (project.discoverySource !== undefined) { - option.meta = { - discoverySource: project.discoverySource, - } satisfies ProjectPathOptionMeta; + if (project.filesystemState === 'deleted') { + option.disabled = true; + } + + if (project.discoverySource !== undefined || project.filesystemState !== undefined) { + const meta: ProjectPathOptionMeta = {}; + if (project.discoverySource !== undefined) { + meta.discoverySource = project.discoverySource; + } + if (project.filesystemState !== undefined) { + meta.filesystemState = project.filesystemState; + } + option.meta = meta; } return option; diff --git a/src/renderer/components/team/dialogs/projectPathProjects.ts b/src/renderer/components/team/dialogs/projectPathProjects.ts index 60dcbbd6..b180a5bc 100644 --- a/src/renderer/components/team/dialogs/projectPathProjects.ts +++ b/src/renderer/components/team/dialogs/projectPathProjects.ts @@ -22,6 +22,14 @@ function mergeDiscoverySource( return 'mixed'; } +function mergeFilesystemState( + current: ProjectPathProject['filesystemState'], + next: ProjectPathProject['filesystemState'] +): ProjectPathProject['filesystemState'] { + if (current === 'available' || next === 'available') return 'available'; + return current ?? next; +} + function getPathName(projectPath: string): string { return projectPath.split(/[/\\]/).filter(Boolean).pop() ?? projectPath; } @@ -47,6 +55,10 @@ function upsertProject( existing.discoverySource, project.discoverySource ); + existing.filesystemState = mergeFilesystemState( + existing.filesystemState, + project.filesystemState + ); if (!existing.mostRecentSession && project.mostRecentSession) { existing.mostRecentSession = project.mostRecentSession; } @@ -58,6 +70,7 @@ function recentProjectToProject(project: { primaryPath: string; mostRecentActivity: number; source: DashboardRecentProjectSource; + filesystemState?: ProjectPathProject['filesystemState']; }): ProjectPathProject { return { id: `recent:${project.id}`, @@ -68,10 +81,13 @@ function recentProjectToProject(project: { createdAt: project.mostRecentActivity, mostRecentSession: project.mostRecentActivity, discoverySource: project.source, + filesystemState: project.filesystemState, }; } -function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project { +function repositoryWorktreeToProject( + worktree: RepositoryGroup['worktrees'][number] +): ProjectPathProject { return { id: worktree.id, path: worktree.path, @@ -79,6 +95,7 @@ function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][numb sessions: [], totalSessions: 0, createdAt: worktree.createdAt ?? Date.now(), + filesystemState: worktree.filesystemState, }; } diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 58c647f8..c3f66dc6 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -65,6 +65,7 @@ interface MessageComposerProps { sendWarning?: string | null; sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null; lastResult?: SendMessageResult | null; + cornerActionPrefix?: React.ReactNode; /** Ref to the underlying textarea element for external focus management. */ textareaRef?: React.Ref; onSend: ( @@ -112,6 +113,7 @@ export const MessageComposer = ({ sendWarning, sendDebugDetails, lastResult, + cornerActionPrefix, textareaRef: externalTextareaRef, onSend, onCrossTeamSend, @@ -143,6 +145,7 @@ export const MessageComposer = ({ const [recipientOpen, setRecipientOpen] = useState(false); const [recipientSearch, setRecipientSearch] = useState(''); const recipientSearchRef = useRef(null); + const [isTextareaFocused, setIsTextareaFocused] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); @@ -642,6 +645,8 @@ export const MessageComposer = ({ }, [canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles] ); + const handleTextareaFocus = useCallback(() => setIsTextareaFocused(true), []); + const handleTextareaBlur = useCallback(() => setIsTextareaFocused(false), []); const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = @@ -666,6 +671,29 @@ export const MessageComposer = ({ Reused recent cross-team request ) : null; + const shouldShowFooterCharCount = remaining < 200; + const shouldShowSavedIndicator = isTextareaFocused && draft.isSaved; + const nonCompactFooterRight = + compactFooterNotice || shouldShowFooterCharCount || shouldShowSavedIndicator ? ( +
+ {compactFooterNotice} + {shouldShowFooterCharCount || shouldShowSavedIndicator ? ( +
+ {shouldShowFooterCharCount ? ( + + {remaining} chars left + + ) : null} + {shouldShowSavedIndicator ? ( + Saved + ) : null} +
+ ) : null} +
+ ) : null; + const composerFooterRight = isCompactLayout ? compactFooterNotice : nonCompactFooterRight; return (
+ {cornerActionPrefix} {/* NOTE: ContextRing disabled — usage formula is inaccurate */} @@ -1108,27 +1139,7 @@ export const MessageComposer = ({
} - footerRight={ - isCompactLayout ? ( - compactFooterNotice - ) : ( -
- {compactFooterNotice} -
- {remaining < 200 ? ( - - {remaining} chars left - - ) : null} - {draft.isSaved ? ( - Saved - ) : null} -
-
- ) - } + footerRight={composerFooterRight} /> diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 54e856b0..ac725343 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -13,6 +13,12 @@ import { Sheet, type SheetRef } from 'react-modal-sheet'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@renderer/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; @@ -33,7 +39,9 @@ import { CheckCheck, ChevronsDownUp, ChevronsUpDown, + Dock, MessageSquare, + MoreHorizontal, PanelBottom, PanelBottomClose, PanelBottomOpen, @@ -807,6 +815,10 @@ export const MessagesPanel = memo(function MessagesPanel({ onPositionChange('bottom-sheet'); }, [onPositionChange]); + const moveToFloatingComposer = useCallback(() => { + onPositionChange('floating-composer'); + }, [onPositionChange]); + const snapBottomSheetTo = useCallback((snapIndex: number) => { setBottomSheetSnapIndex(snapIndex); bottomSheetRef.current?.snapTo(snapIndex); @@ -864,6 +876,53 @@ export const MessagesPanel = memo(function MessagesPanel({ /> ); + const floatingComposerModeControls = ( +
+ + + + + Move to inline + + + + + + Move to bottom sheet + + + + + + Move to sidebar + +
+ ); + const compactComposerSection = ( ); + const floatingComposerSection = ( + + ); + const inlineStatusSection = ( )}
- - - - - - {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} - - - - - - - - {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} - - - - - - - Move to inline - + + + + + + + + Message actions + + + setMessagesCollapsed((v) => !v)}> + {messagesCollapsed ? ( + + ) : ( + + )} + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + setMessagesSearchBarVisible((v) => !v)}> + {messagesSearchBarVisible ? ( + + ) : ( + + )} + {messagesSearchBarVisible ? 'Hide search' : 'Search messages'} + + + + Move to inline + + + + Move to bottom sheet + + + + Float composer + + +
{/* Search & filter bar (toggleable) */} @@ -1126,6 +1202,16 @@ export const MessagesPanel = memo(function MessagesPanel({ ); } + if (position === 'floating-composer') { + return ( +
+
+
{floatingComposerSection}
+
+
+ ); + } + if (position === 'bottom-sheet') { if (!mountPoint) { return @@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({ Move to bottom sheet + + + + + Float composer +