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.
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 (
{card.activeTeams && card.activeTeams.length > 0 && (
@@ -32,9 +39,9 @@ export const RecentProjectCard = ({
-
@@ -42,6 +49,16 @@ export const RecentProjectCard = ({
{card.name}
+ {isDeleted && (
+
+
+
+ Deleted
+
+
+ Project folder no longer exists
+
+ )}
{card.pathSummary && (
@@ -91,21 +108,34 @@ export const RecentProjectCard = ({
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
+ if (isDeleted) {
+ return;
+ }
onOpenPath();
}
}}
- className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
+ className={cn(
+ 'shrink-0 rounded p-0.5 transition-colors',
+ isDeleted
+ ? 'cursor-not-allowed text-red-300/70'
+ : 'cursor-pointer hover:bg-white/5 hover:text-text-secondary'
+ )}
>
-
Open
+
+ {isDeleted ? 'Project folder no longer exists' : 'Open'}
+
diff --git a/src/main/index.ts b/src/main/index.ts
index a8474c66..4e8069e0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -353,6 +353,11 @@ async function createOpenCodeRuntimeAdapterRegistry(
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
+ const useHttpMcpBridge = bridgeEnv.CLAUDE_TEAM_OPENCODE_MCP_HTTP === '1';
+ if (!useHttpMcpBridge) {
+ // The OpenCode bridge direct tools/list proof currently requires a local MCP command.
+ delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
+ }
const applyMcpLaunchSpecEnv = async (
targetEnv: NodeJS.ProcessEnv,
options: { emitProgress?: boolean } = {}
@@ -408,17 +413,19 @@ async function createOpenCodeRuntimeAdapterRegistry(
}`
);
}
- try {
- reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
- const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
- bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
- reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
- } catch (error) {
- logger.warn(
- `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
- error instanceof Error ? error.message : String(error)
- }`
- );
+ if (useHttpMcpBridge) {
+ try {
+ reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
+ const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
+ bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
+ reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
+ } catch (error) {
+ logger.warn(
+ `[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
}
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
await applyMcpLaunchSpecEnv(bridgeEnv, { emitProgress: true });
@@ -427,7 +434,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
const resolveBridgeCommandEnv = async (): Promise => {
const nextEnv = { ...bridgeEnv };
- if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
+ if (!useHttpMcpBridge || !bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
return nextEnv;
}
try {
diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts
index 784aa15e..793f57a5 100644
--- a/src/main/services/discovery/ProjectScanner.ts
+++ b/src/main/services/discovery/ProjectScanner.ts
@@ -30,6 +30,7 @@ import {
import {
type PaginatedSessionsResult,
type Project,
+ type ProjectFilesystemState,
type RepositoryGroup,
type SearchSessionsResult,
type Session,
@@ -82,6 +83,21 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
const MAX_SESSION_IDS_EXPORTED = 200;
+async function resolveProjectFilesystemState(
+ fsProvider: FileSystemProvider,
+ projectPath: string
+): Promise {
+ if (!projectPath.trim()) {
+ return 'deleted';
+ }
+
+ try {
+ return (await fsProvider.exists(projectPath)) ? 'available' : 'deleted';
+ } catch {
+ return 'deleted';
+ }
+}
+
export interface ProjectScannerOptions {
/**
* Directory for the persisted session-list metadata index.
@@ -340,6 +356,7 @@ export class ProjectScanner {
totalSessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
+ filesystemState: project.filesystemState,
},
],
name: project.name,
@@ -360,6 +377,7 @@ export class ProjectScanner {
const encodedId = customPath.replace(/[/\\]/g, '-');
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
const now = Date.now();
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, customPath);
groups.push({
id: encodedId,
@@ -374,6 +392,7 @@ export class ProjectScanner {
sessions: [],
totalSessions: 0,
createdAt: now,
+ filesystemState,
},
],
name: folderName,
@@ -550,6 +569,7 @@ export class ProjectScanner {
cwdHint: firstCwd ?? undefined,
sessionPaths,
});
+ const filesystemState = await resolveProjectFilesystemState(this.fsProvider, actualPath);
// Derive name from resolved path — more reliable than decodePath for
// paths containing dashes (e.g. "test-project" encodes lossily).
@@ -564,6 +584,7 @@ export class ProjectScanner {
totalSessions: allSessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState,
},
];
}
@@ -623,6 +644,10 @@ export class ProjectScanner {
totalSessions: sessionIds.length,
createdAt: Math.floor(createdAt),
mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined,
+ filesystemState: await resolveProjectFilesystemState(
+ this.fsProvider,
+ actualCwd ?? decodedFallback
+ ),
});
}
diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts
index 0458fd1d..bf92c60e 100644
--- a/src/main/services/infrastructure/CliInstallerService.ts
+++ b/src/main/services/infrastructure/CliInstallerService.ts
@@ -501,7 +501,7 @@ export class CliInstallerService {
},
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
},
] as const
).map((provider) => ({
diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
index d79a7f4c..07443151 100644
--- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
+++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
@@ -326,7 +326,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts
index 1c2595ab..b0221f56 100644
--- a/src/main/types/domain.ts
+++ b/src/main/types/domain.ts
@@ -44,6 +44,8 @@ export type MessageCategory = 'user' | 'system' | 'hardNoise' | 'ai' | 'compact'
/**
* Project information derived from ~/.claude/projects/ directory.
*/
+export type ProjectFilesystemState = 'available' | 'deleted';
+
export interface Project {
/** Encoded directory name (e.g., "-Users-username-projectname") */
id: string;
@@ -62,6 +64,8 @@ export interface Project {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for the decoded working directory. */
+ filesystemState?: ProjectFilesystemState;
}
/**
@@ -202,6 +206,8 @@ export interface Worktree {
createdAt: number;
/** Unix timestamp of most recent session activity */
mostRecentSession?: number;
+ /** Filesystem state for this worktree path. */
+ filesystemState?: ProjectFilesystemState;
}
/**
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index 2145b21f..d1f60117 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -385,7 +385,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx
index 2ba585ff..4cd48f64 100644
--- a/src/renderer/components/layout/TeamTabSectionNav.tsx
+++ b/src/renderer/components/layout/TeamTabSectionNav.tsx
@@ -33,7 +33,7 @@ export const TeamTabSectionNav = ({
if (messagesPanelMode === 'sidebar') {
return section.id !== 'messages' && section.id !== 'claude-logs';
}
- if (messagesPanelMode === 'bottom-sheet') {
+ if (messagesPanelMode === 'bottom-sheet' || messagesPanelMode === 'floating-composer') {
return section.id !== 'messages';
}
return true;
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index 85a49f2d..1416f917 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -123,7 +123,7 @@ function getProviderLabel(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index b8c900a4..36d545b5 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -3623,9 +3623,18 @@ export const TeamDetailView = memo(function TeamDetailView({
ref={setMessagesPanelMountPoint}
className="pointer-events-none absolute inset-0 z-30"
/>
- {messagesPanelMode === 'bottom-sheet' && (
+ {messagesPanelMode === 'bottom-sheet' && !graphOpen && (
)}
+ {messagesPanelMode === 'floating-composer' &&
+ isThisTabActive &&
+ isPaneFocused &&
+ !graphOpen && (
+
+ )}
@@ -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 = (
)}
-
-
- setMessagesCollapsed((v) => !v)}
- aria-label={messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
- >
- {messagesCollapsed ? : }
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((v) => !v)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {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
;
@@ -1196,114 +1282,74 @@ export const MessagesPanel = memo(function MessagesPanel({
className="ml-auto flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
>
- {messagesUnreadCount > 0 && (
+
-
-
-
+
+
+
+
+
- Mark all as read
+ Message actions
- )}
-
-
- setMessagesCollapsed((value) => !value)}
- aria-label={
- messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'
- }
- >
+
+ {messagesUnreadCount > 0 && (
+
+
+ Mark all as read
+
+ )}
+ setMessagesCollapsed((value) => !value)}>
{messagesCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
-
-
-
-
- setMessagesSearchBarVisible((value) => !value)}
- aria-label={
- messagesSearchBarVisible ? 'Hide message search' : 'Show message search'
- }
- >
- {messagesSearchBarVisible ? : }
-
-
-
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
-
-
-
-
-
+ {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+
+
+ setMessagesSearchBarVisible((value) => !value)}
>
+ {messagesSearchBarVisible ? (
+
+ ) : (
+
+ )}
+ {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+
{isBottomSheetCollapsed ? (
-
+
) : (
-
+
)}
-
-
-
- {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
-
-
-
-
-
-
-
-
- Move to inline
-
-
-
-
-
-
-
- Move to sidebar
-
+ {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
+
+
+
+ Move to inline
+
+
+
+ Move to sidebar
+
+
+
+ Float composer
+
+
+
@@ -1386,6 +1432,23 @@ export const MessagesPanel = memo(function MessagesPanel({
Move to bottom sheet
+
+
+ {
+ e.stopPropagation();
+ moveToFloatingComposer();
+ }}
+ aria-label="Float messages composer"
+ >
+
+
+
+ Float composer
+
;
}
@@ -133,12 +134,20 @@ export const Combobox = ({
{
+ if (option.disabled) {
+ return;
+ }
onValueChange(option.value);
setOpen(false);
setSearch('');
}}
- className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
+ className={cn(
+ 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]',
+ option.disabled && 'cursor-not-allowed opacity-60'
+ )}
>
{renderOption ? (
renderOption(option, isSelected, search)
diff --git a/src/renderer/components/ui/dropdown-menu.tsx b/src/renderer/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..d1b46d96
--- /dev/null
+++ b/src/renderer/components/ui/dropdown-menu.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
+import * as React from 'react';
+
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
+import { cn } from '@renderer/lib/utils';
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+export {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+};
+/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */
diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts
index d8213c10..40054525 100644
--- a/src/renderer/store/slices/cliInstallerSlice.ts
+++ b/src/renderer/store/slices/cliInstallerSlice.ts
@@ -38,7 +38,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
- { providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
+ { providerId: 'opencode', displayName: 'OpenCode (200+ models)' },
] as const
).map((provider) => ({
...provider,
@@ -500,7 +500,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
case 'gemini':
return 'Gemini';
case 'opencode':
- return 'OpenCode (75+ LLM providers)';
+ return 'OpenCode (200+ models)';
}
}
diff --git a/src/renderer/types/teamMessagesPanelMode.ts b/src/renderer/types/teamMessagesPanelMode.ts
index 87fbaaf1..85f90ec3 100644
--- a/src/renderer/types/teamMessagesPanelMode.ts
+++ b/src/renderer/types/teamMessagesPanelMode.ts
@@ -1 +1 @@
-export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet';
+export type TeamMessagesPanelMode = 'sidebar' | 'inline' | 'bottom-sheet' | 'floating-composer';
diff --git a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
index fd6e655e..556949c6 100644
--- a/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
+++ b/test/features/recent-projects/core/domain/mergeRecentProjectCandidates.test.ts
@@ -96,4 +96,40 @@ describe('mergeRecentProjectCandidates', () => {
expect(result[0].identity).toBe('repo:beta');
expect(result[0].branchName).toBeUndefined();
});
+
+ it('prefers an available candidate over a newer deleted path', () => {
+ const result = mergeRecentProjectCandidates([
+ makeCandidate({
+ lastActivityAt: 1_000,
+ primaryPath: '/workspace/alpha',
+ associatedPaths: ['/workspace/alpha'],
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ }),
+ makeCandidate({
+ lastActivityAt: 5_000,
+ primaryPath: '/workspace/alpha-deleted',
+ associatedPaths: ['/workspace/alpha-deleted'],
+ filesystemState: 'deleted',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha-deleted',
+ },
+ }),
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ primaryPath: '/workspace/alpha',
+ lastActivityAt: 5_000,
+ filesystemState: 'available',
+ openTarget: {
+ type: 'synthetic-path',
+ path: '/workspace/alpha',
+ },
+ });
+ });
});
diff --git a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
index afc4418b..0e429ea5 100644
--- a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
+++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts
@@ -114,6 +114,42 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => {
expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha');
});
+ it('marks a Codex session project as deleted when its cwd is gone', async () => {
+ const codexHome = path.join(tempDir, '.codex');
+ const logger = createLogger();
+ const identityResolver = {
+ resolve: vi.fn().mockResolvedValue(null),
+ } as unknown as RecentProjectIdentityResolver;
+ const fsProvider = {
+ exists: vi.fn().mockResolvedValue(false),
+ };
+ await writeRollout(
+ path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-deleted.jsonl'),
+ {
+ cwd: '/Users/test/projects/deleted',
+ },
+ new Date('2026-04-14T12:00:00.000Z')
+ );
+
+ const adapter = new CodexSessionFileRecentProjectsSourceAdapter({
+ getActiveContext: () => ({ type: 'local', id: 'local-1', fsProvider }) as never,
+ getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
+ identityResolver,
+ logger,
+ codexHome,
+ });
+
+ const result = await adapter.list();
+
+ expect(result.candidates[0]).toEqual(
+ expect.objectContaining({
+ primaryPath: '/Users/test/projects/deleted',
+ filesystemState: 'deleted',
+ })
+ );
+ expect(fsProvider.exists).toHaveBeenCalledWith('/Users/test/projects/deleted');
+ });
+
it('loads Codex projects from large session metadata lines without parsing the full line', async () => {
const codexHome = path.join(tempDir, '.codex');
const logger = createLogger();
diff --git a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
index 90e65166..35ee6e31 100644
--- a/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
+++ b/test/features/recent-projects/renderer/adapters/RecentProjectsSectionAdapter.test.ts
@@ -21,6 +21,7 @@ describe('adaptRecentProjectsSection', () => {
worktreeId: 'wt-alpha',
},
primaryBranch: 'main',
+ filesystemState: 'deleted',
};
const activeTeam: TeamSummary = {
@@ -52,11 +53,11 @@ describe('adaptRecentProjectsSection', () => {
taskCounts: { pending: 5, inProgress: 7, completed: 9 },
additionalPathCount: 1,
primaryBranch: 'main',
+ filesystemState: 'deleted',
activeTeams: [activeTeam],
pathSummary: {
badgeLabel: '2 paths',
- description:
- 'This card merges recent activity from related worktrees and project paths.',
+ description: 'This card merges recent activity from related worktrees and project paths.',
paths: [
{
label: 'Primary path',
diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
index a26ddc14..1d5047d3 100644
--- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
+++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
@@ -153,4 +153,26 @@ describe('ProjectScanner cwd split logic', () => {
expect(worktree?.isMainWorktree).toBe(false);
expect(worktree?.source).toBe('claude-desktop');
});
+
+ it('marks decoded project paths as deleted when the working directory no longer exists', async () => {
+ const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
+ tempDirs.push(projectsDir);
+
+ const encodedName = '-Users-test-deleted-project';
+ const projectDir = path.join(projectsDir, encodedName);
+ fs.mkdirSync(projectDir);
+
+ fs.writeFileSync(
+ path.join(projectDir, 'session-deleted.jsonl'),
+ createSessionLine({ cwd: '/Users/test/deleted-project' }) + '\n'
+ );
+
+ const scanner = new ProjectScanner(projectsDir);
+ const projects = await scanner.scan();
+
+ expect(projects[0]).toMatchObject({
+ path: '/Users/test/deleted-project',
+ filesystemState: 'deleted',
+ });
+ });
});
diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts
index 39e08c66..140454b7 100644
--- a/test/main/services/infrastructure/CliInstallerService.test.ts
+++ b/test/main/services/infrastructure/CliInstallerService.test.ts
@@ -151,7 +151,7 @@ describe('CliInstallerService', () => {
'opencode',
]);
expect(openCodeStatus).toMatchObject({
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
statusMessage: 'Runtime not found.',
canLoginFromUi: false,
diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
index dd039aec..df94eae6 100644
--- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
+++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
@@ -221,7 +221,7 @@ describe('ClaudeMultimodelBridgeService', () => {
});
expect(providers[3]).toMatchObject({
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
models: [],
diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts
index ff2affe4..061853eb 100644
--- a/test/renderer/components/cli/CliStatusVisibility.test.ts
+++ b/test/renderer/components/cli/CliStatusVisibility.test.ts
@@ -450,7 +450,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -476,7 +476,7 @@ describe('CLI status visibility during completed install state', () => {
await Promise.resolve();
});
- expect(host.textContent).toContain('OpenCode (75+ LLM providers)');
+ expect(host.textContent).toContain('OpenCode (200+ models)');
expect(host.textContent).toContain('Install');
const installButton = Array.from(host.querySelectorAll('button')).find(
@@ -523,7 +523,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: false,
authenticated: false,
authMethod: null,
@@ -575,7 +575,7 @@ describe('CLI status visibility during completed install state', () => {
providers: [
{
providerId: 'opencode',
- displayName: 'OpenCode (75+ LLM providers)',
+ displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_managed',
diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
index a19ce837..b71bae74 100644
--- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts
+++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts
@@ -559,7 +559,7 @@ describe('SkillsPanel', () => {
});
expect(host.textContent).toContain(
- 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (75+ LLM providers).'
+ 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (200+ models).'
);
expect(host.textContent).toContain('Codex only');
diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
index 4f794a3a..d8d33952 100644
--- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
+++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts
@@ -1405,6 +1405,12 @@ describe('LaunchTeamDialog', () => {
expect(host.textContent).toContain('model:claude-opus-4-6');
expect(host.textContent).toContain('effort:max');
expect(host.textContent).toContain('fast:on');
+ expect(host.textContent).toContain('monthly Agent SDK credit');
+ expect(
+ host.querySelector(
+ 'a[href="https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan"]'
+ )
+ ).toBeTruthy();
const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Save Changes'
diff --git a/test/renderer/components/team/dialogs/projectPathOptions.test.ts b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
index 33685f88..2af0d127 100644
--- a/test/renderer/components/team/dialogs/projectPathOptions.test.ts
+++ b/test/renderer/components/team/dialogs/projectPathOptions.test.ts
@@ -88,4 +88,27 @@ describe('buildProjectPathOptions', () => {
},
]);
});
+
+ it('marks deleted project paths as disabled options', () => {
+ const options = buildProjectPathOptions([
+ createProject({
+ id: 'project-deleted',
+ name: 'my-tes',
+ path: '/Users/belief/dev/projects/my-tes',
+ filesystemState: 'deleted',
+ }),
+ ]);
+
+ expect(options).toEqual([
+ {
+ value: '/Users/belief/dev/projects/my-tes',
+ label: 'my-tes',
+ description: '/Users/belief/dev/projects/my-tes',
+ disabled: true,
+ meta: {
+ filesystemState: 'deleted',
+ },
+ },
+ ]);
+ });
});
From 4adc233fa4bf71fcc7d908b98a3a8bb4cac96e9e Mon Sep 17 00:00:00 2001
From: infiniti <52129260+developerInfiniti@users.noreply.github.com>
Date: Sat, 16 May 2026 17:34:50 +0300
Subject: [PATCH 10/11] fix: harden Windows frontend path handling
Harden Windows path handling and packaged app smoke checks.
---
scripts/electron-builder/dist-invocations.cjs | 51 +++++++++
scripts/electron-builder/dist.mjs | 49 +--------
scripts/electron-builder/smokePackagedApp.cjs | 56 +++++++++-
.../utils/recentProjectOpenHistory.ts | 3 +-
src/main/services/editor/FileSearchService.ts | 4 +-
.../DirectoryTree/buildDirectoryTree.ts | 11 +-
.../components/chat/viewers/FileLink.tsx | 15 ++-
.../chat/viewers/MarkdownViewer.tsx | 6 +-
.../settings/sections/GeneralSection.tsx | 4 +-
.../team/editor/EditorContextMenu.tsx | 9 +-
.../team/editor/SearchInFilesPanel.tsx | 8 +-
src/renderer/hooks/useFileSuggestions.ts | 25 +++--
src/renderer/store/utils/pathResolution.ts | 29 ++---
src/renderer/utils/claudeMdTracker.ts | 69 ++++++------
src/renderer/utils/contextTracker.ts | 99 +++++++++++------
src/renderer/utils/pathDisplay.ts | 24 ++--
src/shared/utils/platformPath.ts | 21 ++++
.../utils/recentProjectOpenHistory.test.ts | 19 ++++
.../build/electronBuilderDistScript.test.ts | 10 +-
.../services/editor/FileSearchService.test.ts | 25 +++++
.../buildDirectoryTree.test.ts | 47 ++++++++
.../chat/viewers/MarkdownViewer.test.tsx | 25 ++++-
test/renderer/components/fileLink.test.ts | 15 +++
.../renderer/hooks/useFileSuggestions.test.ts | 41 ++++++-
test/renderer/store/pathResolution.test.ts | 24 ++++
test/renderer/utils/claudeMdTracker.test.ts | 103 ++++++++++++++++++
test/renderer/utils/contextTracker.test.ts | 66 +++++++++++
test/renderer/utils/pathDisplay.test.ts | 27 ++++-
test/shared/utils/platformPath.test.ts | 38 +++++++
29 files changed, 714 insertions(+), 209 deletions(-)
create mode 100644 scripts/electron-builder/dist-invocations.cjs
create mode 100644 test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
create mode 100644 test/renderer/utils/contextTracker.test.ts
create mode 100644 test/shared/utils/platformPath.test.ts
diff --git a/scripts/electron-builder/dist-invocations.cjs b/scripts/electron-builder/dist-invocations.cjs
new file mode 100644
index 00000000..a8f7db1c
--- /dev/null
+++ b/scripts/electron-builder/dist-invocations.cjs
@@ -0,0 +1,51 @@
+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',
+];
+
+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 : []),
+ ],
+ }));
+}
+
+module.exports = {
+ buildElectronBuilderInvocations,
+};
diff --git a/scripts/electron-builder/dist.mjs b/scripts/electron-builder/dist.mjs
index e3c72615..cc98ed61 100644
--- a/scripts/electron-builder/dist.mjs
+++ b/scripts/electron-builder/dist.mjs
@@ -4,54 +4,9 @@ import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
+const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs');
-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 : []),
- ],
- }));
-}
+export { buildElectronBuilderInvocations };
async function runElectronBuilder(args) {
const cliPath = require.resolve('electron-builder/cli.js');
diff --git a/scripts/electron-builder/smokePackagedApp.cjs b/scripts/electron-builder/smokePackagedApp.cjs
index 68594830..9892a072 100644
--- a/scripts/electron-builder/smokePackagedApp.cjs
+++ b/scripts/electron-builder/smokePackagedApp.cjs
@@ -5,6 +5,7 @@ const { spawn } = require('node:child_process');
const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000);
const POST_STARTUP_STABLE_MS = Number(process.env.PACKAGED_SMOKE_STABLE_MS ?? 8_000);
+const SHUTDOWN_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_SHUTDOWN_TIMEOUT_MS ?? 5_000);
const REQUIRED_LOG_MARKERS = ['renderer did-finish-load'];
const FAILURE_PATTERNS = [
/Cannot find module/i,
@@ -39,7 +40,10 @@ function findExecutable(bundlePath, platform) {
if (platform === 'win32') {
const executable = fs
.readdirSync(bundlePath)
- .find((entry) => entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall'));
+ .find(
+ (entry) =>
+ entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall')
+ );
if (!executable) fail(`No .exe found in ${bundlePath}`);
return path.join(bundlePath, executable);
}
@@ -66,6 +70,45 @@ function findExecutable(bundlePath, platform) {
fail(`Unsupported platform: ${platform}`);
}
+function waitForProcessClose(child, exitPromise, timeoutMs) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return Promise.resolve(true);
+ }
+
+ let timeoutId;
+ const timeoutPromise = new Promise((resolve) => {
+ timeoutId = setTimeout(() => resolve(false), timeoutMs);
+ });
+ return Promise.race([exitPromise.then(() => true), timeoutPromise]).finally(() => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ });
+}
+
+async function terminateChild(child, exitPromise, platform) {
+ if (child.exitCode !== null || child.signalCode !== null) {
+ return;
+ }
+
+ if (platform === 'win32' && child.pid) {
+ await new Promise((resolve) => {
+ const killer = spawn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
+ stdio: 'ignore',
+ });
+ killer.once('error', resolve);
+ killer.once('close', resolve);
+ });
+ } else {
+ child.kill();
+ }
+
+ const closed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS);
+ if (!closed && child.exitCode === null && child.signalCode === null) {
+ throw new Error(`Timed out after ${SHUTDOWN_TIMEOUT_MS}ms waiting for packaged app to exit`);
+ }
+}
+
async function main() {
const [bundlePathArg, platform] = process.argv.slice(2);
if (!bundlePathArg || !platform) {
@@ -100,7 +143,7 @@ async function main() {
let startupSeenAt = null;
while (Date.now() < deadline) {
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
- child.kill();
+ await terminateChild(child, exitPromise, platform);
fail('Detected startup failure pattern', log);
}
@@ -109,7 +152,7 @@ async function main() {
}
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
- child.kill();
+ await terminateChild(child, exitPromise, platform);
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
return;
}
@@ -119,11 +162,14 @@ async function main() {
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
]);
if (exit) {
- fail(`Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`, log);
+ fail(
+ `Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`,
+ log
+ );
}
}
- child.kill();
+ await terminateChild(child, exitPromise, platform);
fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log);
}
diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
index 28d8b880..0b3be39c 100644
--- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
+++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts
@@ -1,4 +1,5 @@
import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath';
+import { normalizePathForComparison } from '@shared/utils/platformPath';
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
@@ -34,7 +35,7 @@ function normalizeHistoryPath(projectPath: string): string | null {
normalizedPath = normalizedPath.slice(0, -1);
}
}
- return normalizedPath;
+ return normalizedPath ? normalizePathForComparison(normalizedPath) : null;
}
function foldHistoryPath(projectPath: string): string {
diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts
index 52ce0927..691f3603 100644
--- a/src/main/services/editor/FileSearchService.ts
+++ b/src/main/services/editor/FileSearchService.ts
@@ -292,9 +292,7 @@ export class FileSearchService {
subdirs.push(fullPath);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
- const relativePath = fullPath.startsWith(projectRoot)
- ? fullPath.slice(projectRoot.length + 1)
- : entry.name;
+ const relativePath = path.relative(projectRoot, fullPath).split(path.sep).join('/');
files.push({ path: fullPath, name: entry.name, relativePath });
}
}
diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
index 48e38d43..ec68af98 100644
--- a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
+++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts
@@ -2,6 +2,8 @@
* Build a directory tree structure from CLAUDE.md injections.
*/
+import { getRelativePathWithinPrefix } from '@shared/utils/platformPath';
+
import type { TreeNode } from './types';
import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
@@ -15,12 +17,9 @@ export function buildDirectoryTree(
const root: TreeNode = { name: '', path: '', isFile: false, children: new Map() };
for (const injection of injections) {
- let relativePath = injection.path;
- if (projectRoot && relativePath.startsWith(projectRoot)) {
- relativePath = relativePath.slice(projectRoot.length);
- if (relativePath.startsWith('/') || relativePath.startsWith('\\'))
- relativePath = relativePath.slice(1);
- }
+ const relativePath = projectRoot
+ ? (getRelativePathWithinPrefix(projectRoot, injection.path) ?? injection.path)
+ : injection.path;
const parts = relativePath.split(/[\\/]/);
let current = root;
diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx
index 7ed49cf7..4b1017b8 100644
--- a/src/renderer/components/chat/viewers/FileLink.tsx
+++ b/src/renderer/components/chat/viewers/FileLink.tsx
@@ -57,21 +57,20 @@ export function resolveFileLinkPath(filePath: string, projectPath: string): stri
function normalizePathSegments(filePath: string): string {
const hasBackslash = filePath.includes('\\') && !filePath.includes('/');
const separator = hasBackslash ? '\\' : '/';
- const normalized = filePath.replace(/[/\\]+/g, separator);
let prefix = '';
- let body = normalized;
+ let body = filePath;
- const driveMatch = /^([A-Za-z]:)[\\/]/.exec(normalized);
+ const driveMatch = /^([A-Za-z]:)[\\/]/.exec(filePath);
if (driveMatch) {
prefix = `${driveMatch[1]}${separator}`;
- body = normalized.slice(prefix.length);
- } else if (normalized.startsWith(`${separator}${separator}`)) {
+ body = filePath.slice(driveMatch[0].length);
+ } else if (filePath.startsWith('\\\\') || filePath.startsWith('//')) {
prefix = `${separator}${separator}`;
- body = normalized.slice(2);
- } else if (normalized.startsWith(separator)) {
+ body = filePath.slice(2);
+ } else if (filePath.startsWith('/') || filePath.startsWith('\\')) {
prefix = separator;
- body = normalized.slice(1);
+ body = filePath.slice(1);
}
const segments: string[] = [];
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index 90faaf38..cca878d4 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -27,6 +27,7 @@ import {
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
+import { resolveFilePath } from '@renderer/store/utils/pathResolution';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { nameColorSet } from '@renderer/utils/projectColor';
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
@@ -318,9 +319,8 @@ function isAllowedElement(element: { tagName: string }): boolean {
}
/** Resolve a relative path to an absolute path given a base directory */
-function resolveRelativePath(relativeSrc: string, baseDir: string): string {
- const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
- return `${baseDir}/${cleaned}`;
+export function resolveRelativePath(relativeSrc: string, baseDir: string): string {
+ return resolveFilePath(baseDir, relativeSrc);
}
// =============================================================================
diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx
index 0e653f52..dd0ef9a1 100644
--- a/src/renderer/components/settings/sections/GeneralSection.tsx
+++ b/src/renderer/components/settings/sections/GeneralSection.tsx
@@ -254,7 +254,9 @@ export const GeneralSection = ({
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
const isWindowsStyleDefaultPath =
- /^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
+ /^[a-zA-Z]:[/\\]/.test(defaultClaudeRootPath) ||
+ defaultClaudeRootPath.startsWith('\\\\') ||
+ defaultClaudeRootPath.startsWith('//');
const isElectron = useMemo(() => isElectronMode(), []);
diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx
index 5a795ff9..befabb3f 100644
--- a/src/renderer/components/team/editor/EditorContextMenu.tsx
+++ b/src/renderer/components/team/editor/EditorContextMenu.tsx
@@ -9,7 +9,7 @@
import React, { useCallback, useRef, useState } from 'react';
import * as ContextMenu from '@radix-ui/react-context-menu';
-import { lastSeparatorIndex } from '@shared/utils/platformPath';
+import { getRelativePathWithinPrefix, lastSeparatorIndex } from '@shared/utils/platformPath';
import {
ClipboardCopy,
FilePlus,
@@ -87,6 +87,8 @@ export const EditorContextMenu = ({
? target.path
: target.path.substring(0, lastSeparatorIndex(target.path))
: null;
+ const targetRelativePath =
+ projectPath && target ? getRelativePathWithinPrefix(projectPath, target.path) : null;
return (
@@ -154,12 +156,11 @@ export const EditorContextMenu = ({
Copy Path
- {projectPath && target.path.startsWith(projectPath) && (
+ {targetRelativePath !== null && (
{
- const relative = target.path.slice(projectPath.length + 1);
- void navigator.clipboard.writeText(relative);
+ void navigator.clipboard.writeText(targetRelativePath);
}}
>
diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
index 4253a22d..81d57b58 100644
--- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx
+++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
@@ -10,7 +10,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
-import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
+import {
+ getBasename,
+ getRelativePathWithinPrefix,
+ lastSeparatorIndex,
+} from '@shared/utils/platformPath';
import { Loader2, Search, X } from 'lucide-react';
import { FileIcon } from './FileIcon';
@@ -146,7 +150,7 @@ export const SearchInFilesPanel = ({
const getRelativePath = useCallback(
(filePath: string) => {
- return filePath.startsWith(projectPath) ? filePath.slice(projectPath.length + 1) : filePath;
+ return getRelativePathWithinPrefix(projectPath, filePath) ?? filePath;
},
[projectPath]
);
diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts
index 15b8541e..f5a8449c 100644
--- a/src/renderer/hooks/useFileSuggestions.ts
+++ b/src/renderer/hooks/useFileSuggestions.ts
@@ -13,6 +13,7 @@ import {
onQuickOpenCacheInvalidated,
setQuickOpenCache,
} from '@renderer/utils/quickOpenCache';
+import { joinPath, splitPath } from '@shared/utils/platformPath';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { QuickOpenFile } from '@shared/types/editor';
@@ -53,12 +54,12 @@ export function formatFileMentionPath(relativePath: string): string {
* Extracts unique directories from a list of file paths.
* Returns directories sorted by depth (shallower first), then alphabetically.
*/
-function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] {
+export function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] {
const dirSet = new Set();
for (const f of files) {
// Walk up the directory chain from each file's relative path
- const parts = f.relativePath.split('/');
+ const parts = splitPath(f.relativePath);
// Remove the file name — keep only directory segments
for (let i = 1; i < parts.length; i++) {
dirSet.add(parts.slice(0, i).join('/'));
@@ -67,19 +68,19 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive
const folders: DerivedFolder[] = [];
for (const relDir of dirSet) {
- const segments = relDir.split('/');
+ const segments = splitPath(relDir);
const name = segments[segments.length - 1];
folders.push({
name,
relativePath: relDir + '/',
- absolutePath: projectPath + '/' + relDir,
+ absolutePath: joinPath(projectPath, ...splitPath(relDir)),
});
}
// Sort: shallower first, then alphabetically
folders.sort((a, b) => {
- const depthA = a.relativePath.split('/').length;
- const depthB = b.relativePath.split('/').length;
+ const depthA = splitPath(a.relativePath).length;
+ const depthB = splitPath(b.relativePath).length;
if (depthA !== depthB) return depthA - depthB;
return a.relativePath.localeCompare(b.relativePath);
});
@@ -94,13 +95,14 @@ function extractDirectories(files: QuickOpenFile[], projectPath: string): Derive
export function filterFileSuggestions(files: QuickOpenFile[], query: string): MentionSuggestion[] {
if (!query || files.length === 0) return [];
- const lower = query.toLowerCase();
+ const lower = query.replace(/\\/g, '/').toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of files) {
if (results.length >= MAX_FILE_SUGGESTIONS) break;
- if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
+ const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase();
+ if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) {
results.push({
id: `file:${f.path}`,
name: f.name,
@@ -127,14 +129,15 @@ export function filterFolderSuggestions(
if (!query || folders.length === 0) return [];
// Strip trailing slash from query for matching (e.g. "ui/" -> "ui")
- const cleanQuery = query.endsWith('/') ? query.slice(0, -1) : query;
- const lower = cleanQuery.toLowerCase();
+ const cleanQuery = query.endsWith('/') || query.endsWith('\\') ? query.slice(0, -1) : query;
+ const lower = cleanQuery.replace(/\\/g, '/').toLowerCase();
const results: MentionSuggestion[] = [];
for (const f of folders) {
if (results.length >= MAX_FOLDER_SUGGESTIONS) break;
- if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) {
+ const relativePathForMatch = f.relativePath.replace(/\\/g, '/').toLowerCase();
+ if (f.name.toLowerCase().includes(lower) || relativePathForMatch.includes(lower)) {
results.push({
id: `folder:${f.absolutePath}`,
name: f.name + '/',
diff --git a/src/renderer/store/utils/pathResolution.ts b/src/renderer/store/utils/pathResolution.ts
index 7d520af8..43b58f0f 100644
--- a/src/renderer/store/utils/pathResolution.ts
+++ b/src/renderer/store/utils/pathResolution.ts
@@ -2,6 +2,8 @@
* Path resolution utilities for the store.
*/
+import { stripTrailingSeparators } from '@shared/utils/platformPath';
+
/**
* Resolves a relative path against a base path, handling various path formats.
* Handles:
@@ -17,7 +19,7 @@ export function resolveFilePath(base: string, relativePath: string): string {
return relativePath;
}
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relativePath;
@@ -32,21 +34,22 @@ export function resolveFilePath(base: string, relativePath: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -56,8 +59,8 @@ export function resolveFilePath(base: string, relativePath: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
@@ -66,18 +69,6 @@ function isAbsolutePath(input: string): boolean {
return input.startsWith('/') || input.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(input);
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
diff --git a/src/renderer/utils/claudeMdTracker.ts b/src/renderer/utils/claudeMdTracker.ts
index 5510d570..ac045070 100644
--- a/src/renderer/utils/claudeMdTracker.ts
+++ b/src/renderer/utils/claudeMdTracker.ts
@@ -8,8 +8,11 @@
*/
import {
+ isPathPrefix,
lastSeparatorIndex,
+ normalizePathForComparison,
splitPath as splitPathCrossPlatform,
+ stripTrailingSeparators,
} from '@shared/utils/platformPath';
import { extractFileReferences } from './groupTransformer';
@@ -64,7 +67,14 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string {
* Check if a path is absolute (starts with /).
*/
function isAbsolutePath(path: string): boolean {
- return path.startsWith('/') || path.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(path);
+ return (
+ path.startsWith('/') ||
+ path.startsWith('~/') ||
+ path.startsWith('~\\') ||
+ path === '~' ||
+ path.startsWith('\\\\') ||
+ /^[a-zA-Z]:[\\/]/.test(path)
+ );
}
/**
@@ -82,7 +92,7 @@ function joinPaths(base: string, relative: string): string {
}
// Remove trailing slash from base if present
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relative;
@@ -91,20 +101,21 @@ function joinPaths(base: string, relative: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -114,24 +125,12 @@ function joinPaths(base: string, relative: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
@@ -158,7 +157,19 @@ function splitPath(input: string): string[] {
}
function normalizeForComparison(input: string): string {
- return input.replace(/\\/g, '/');
+ return normalizePathForComparison(input);
+}
+
+function createSeenPathSet(paths: string[]): Set {
+ return new Set(paths.map(normalizeForComparison));
+}
+
+function hasSeenPath(seenPaths: Set, path: string): boolean {
+ return seenPaths.has(normalizeForComparison(path));
+}
+
+function rememberPath(seenPaths: Set, path: string): void {
+ seenPaths.add(normalizeForComparison(path));
}
/**
@@ -183,11 +194,7 @@ export function getParentDirectory(dirPath: string): string | null {
* Check if dirPath is at or above stopPath in the directory tree.
*/
function isAtOrAbove(dirPath: string, stopPath: string): boolean {
- const normDir = normalizeForComparison(dirPath).replace(/\/$/, '');
- const normStop = normalizeForComparison(stopPath).replace(/\/$/, '');
-
- // dirPath is at or above stopPath if stopPath starts with dirPath
- return normStop === normDir || normStop.startsWith(normDir + '/');
+ return isPathPrefix(dirPath, stopPath);
}
// =============================================================================
@@ -485,7 +492,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
} = params;
const newInjections: ClaudeMdInjection[] = [];
- const previousPaths = new Set(previousInjections.map((inj) => inj.path));
+ const previousPaths = createSeenPathSet(previousInjections.map((inj) => inj.path));
// For the first group, add global injections
// Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel
@@ -493,9 +500,9 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
if (isFirstGroup) {
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData);
for (const injection of globalInjections) {
- if (!previousPaths.has(injection.path)) {
+ if (!hasSeenPath(previousPaths, injection.path)) {
newInjections.push(injection);
- previousPaths.add(injection.path);
+ rememberPath(previousPaths, injection.path);
}
}
}
@@ -526,7 +533,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
for (const claudeMdPath of claudeMdPaths) {
// Skip if already seen
- if (previousPaths.has(claudeMdPath)) {
+ if (hasSeenPath(previousPaths, claudeMdPath)) {
continue;
}
@@ -546,7 +553,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats
// Create directory injection
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
newInjections.push(injection);
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
}
}
diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts
index 78eb4986..ec8ecd8e 100644
--- a/src/renderer/utils/contextTracker.ts
+++ b/src/renderer/utils/contextTracker.ts
@@ -9,6 +9,7 @@
* This builds on claudeMdTracker.ts and extends it to track all context sources.
*/
+import { normalizePathForComparison, stripTrailingSeparators } from '@shared/utils/platformPath';
import { estimateTokens } from '@shared/utils/tokenFormatting';
import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection';
@@ -474,7 +475,7 @@ function joinPaths(base: string, relative: string): string {
return relative;
}
- const cleanBase = trimTrailingSeparator(base);
+ const cleanBase = stripTrailingSeparators(base);
// Handle @ prefix (file mention marker) - strip it if present
let cleanRelative = relative;
@@ -483,20 +484,21 @@ function joinPaths(base: string, relative: string): string {
}
// Handle ./ prefix (current directory)
- if (cleanRelative.startsWith('./')) {
+ if (cleanRelative.startsWith('./') || cleanRelative.startsWith('.\\')) {
cleanRelative = cleanRelative.slice(2);
}
// Handle ../ prefixes (parent directory)
const separator = cleanBase.includes('\\') ? '\\' : '/';
- const hasUnixRoot = cleanBase.startsWith('/');
- const hasUncRoot = cleanBase.startsWith('\\\\');
+ const hasUncRoot = cleanBase.startsWith('\\\\') || cleanBase.startsWith('//');
+ const hasUnixRoot = !hasUncRoot && cleanBase.startsWith('/');
+ const minRootParts = hasUncRoot ? 2 : 1;
const normalizedRelative = normalizeSeparators(cleanRelative, separator);
const baseParts = splitPath(cleanBase);
let remainingRelative = normalizedRelative;
while (remainingRelative.startsWith(`..${separator}`)) {
remainingRelative = remainingRelative.slice(3);
- if (baseParts.length > 1) {
+ if (baseParts.length > minRootParts) {
baseParts.pop();
}
}
@@ -506,24 +508,12 @@ function joinPaths(base: string, relative: string): string {
if (hasUnixRoot && !normalizedBase.startsWith('/')) {
normalizedBase = `/${normalizedBase}`;
}
- if (hasUncRoot && !normalizedBase.startsWith('\\\\')) {
- normalizedBase = `\\\\${normalizedBase}`;
+ if (hasUncRoot && !normalizedBase.startsWith(`${separator}${separator}`)) {
+ normalizedBase = `${separator}${separator}${normalizedBase}`;
}
return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase;
}
-function trimTrailingSeparator(input: string): string {
- let end = input.length;
- while (end > 0) {
- const char = input[end - 1];
- if (char !== '/' && char !== '\\') {
- break;
- }
- end--;
- }
- return input.slice(0, end);
-}
-
function normalizeSeparators(input: string, separator: '/' | '\\'): string {
let output = '';
let prevWasSeparator = false;
@@ -567,7 +557,50 @@ function splitPath(input: string): string[] {
}
function normalizeForComparison(input: string): string {
- return input.replace(/\\/g, '/');
+ return normalizePathForComparison(input);
+}
+
+function createSeenPathSet(paths: string[]): Set {
+ return new Set(paths.map(normalizeForComparison));
+}
+
+function hasSeenPath(seenPaths: Set, path: string): boolean {
+ return seenPaths.has(normalizeForComparison(path));
+}
+
+function rememberPath(seenPaths: Set, path: string): void {
+ seenPaths.add(normalizeForComparison(path));
+}
+
+function getRecordValueByPath(
+ record: Record | undefined,
+ path: string
+): T | undefined {
+ if (!record) return undefined;
+ const exact = record[path];
+ if (exact !== undefined) return exact;
+
+ const normalizedPath = normalizeForComparison(path);
+ for (const [key, value] of Object.entries(record)) {
+ if (normalizeForComparison(key) === normalizedPath) {
+ return value;
+ }
+ }
+ return undefined;
+}
+
+function getMapValueByPath(map: Map | undefined, path: string): T | undefined {
+ if (!map) return undefined;
+ const exact = map.get(path);
+ if (exact !== undefined) return exact;
+
+ const normalizedPath = normalizeForComparison(path);
+ for (const [key, value] of map.entries()) {
+ if (normalizeForComparison(key) === normalizedPath) {
+ return value;
+ }
+ }
+ return undefined;
}
/**
@@ -604,7 +637,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
} = params;
const newInjections: ContextInjection[] = [];
- const previousPaths = new Set(
+ const previousPaths = createSeenPathSet(
previousInjections
.filter(
(inj): inj is ClaudeMdContextInjection | MentionedFileInjection =>
@@ -620,9 +653,9 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
if (isFirstGroup) {
const globalInjections = createGlobalInjections(projectRoot, turnGroupId, claudeMdTokenData);
for (const injection of globalInjections) {
- if (!previousPaths.has(injection.path)) {
+ if (!hasSeenPath(previousPaths, injection.path)) {
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(injection.path);
+ rememberPath(previousPaths, injection.path);
}
}
}
@@ -654,7 +687,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
for (const claudeMdPath of claudeMdPaths) {
// Skip if already seen
- if (previousPaths.has(claudeMdPath)) {
+ if (hasSeenPath(previousPaths, claudeMdPath)) {
continue;
}
@@ -674,7 +707,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
// Only include directory CLAUDE.md files that exist (validated via directoryTokenData)
// If directoryTokenData is provided and doesn't contain this path, the file doesn't exist
if (directoryTokenData) {
- const fileInfo = directoryTokenData[claudeMdPath];
+ const fileInfo = getRecordValueByPath(directoryTokenData, claudeMdPath);
if (!fileInfo || !fileInfo.exists || fileInfo.estimatedTokens <= 0) {
// File doesn't exist or has no content - skip it
continue;
@@ -683,12 +716,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
injection.estimatedTokens = fileInfo.estimatedTokens;
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
} else {
// Fallback: if no directoryTokenData provided, create with default tokens (legacy behavior)
const injection = createDirectoryInjection(claudeMdPath, turnGroupId);
newInjections.push(wrapClaudeMdInjection(injection));
- previousPaths.add(claudeMdPath);
+ rememberPath(previousPaths, claudeMdPath);
}
}
}
@@ -704,12 +737,12 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
: joinPaths(projectRoot, fileRef.path);
// Skip if already seen
- if (previousPaths.has(absolutePath)) {
+ if (hasSeenPath(previousPaths, absolutePath)) {
continue;
}
// Check if we have token data for this file
- const fileInfo = mentionedFileTokenData?.get(absolutePath);
+ const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath);
// Only include files that exist and are under the token limit
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
@@ -723,7 +756,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
});
newInjections.push(mentionedFileInjection);
- previousPaths.add(absolutePath);
+ rememberPath(previousPaths, absolutePath);
}
}
}
@@ -736,11 +769,11 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
? fileRef.path
: joinPaths(projectRoot, fileRef.path);
- if (previousPaths.has(absolutePath)) {
+ if (hasSeenPath(previousPaths, absolutePath)) {
continue;
}
- const fileInfo = mentionedFileTokenData?.get(absolutePath);
+ const fileInfo = getMapValueByPath(mentionedFileTokenData, absolutePath);
if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) {
const mentionedFileInjection = createMentionedFileInjection({
@@ -753,7 +786,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
});
newInjections.push(mentionedFileInjection);
- previousPaths.add(absolutePath);
+ rememberPath(previousPaths, absolutePath);
}
}
diff --git a/src/renderer/utils/pathDisplay.ts b/src/renderer/utils/pathDisplay.ts
index ccdd6808..a9955671 100644
--- a/src/renderer/utils/pathDisplay.ts
+++ b/src/renderer/utils/pathDisplay.ts
@@ -9,7 +9,7 @@
* Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute).
*/
-import { splitPath } from '@shared/utils/platformPath';
+import { getRelativePathWithinPrefix, splitPath } from '@shared/utils/platformPath';
function isWindowsAbsolutePath(input: string): boolean {
return /^[A-Za-z]:[/\\]/.test(input) || input.startsWith('\\\\') || input.startsWith('//');
@@ -38,15 +38,9 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
// 1. Make relative to project root
if (projectRoot) {
- const root = projectRoot.replace(/[/\\]$/, '');
- const caseInsensitive = isWindowsAbsolutePath(p) || isWindowsAbsolutePath(root);
- const pathForCompare = caseInsensitive ? p.toLowerCase() : p;
- const rootForCompare = caseInsensitive ? root.toLowerCase() : root;
- if (
- pathForCompare.startsWith(rootForCompare + '/') ||
- pathForCompare.startsWith(rootForCompare + '\\')
- ) {
- p = p.slice(root.length + 1);
+ const relativePath = getRelativePathWithinPrefix(projectRoot, p);
+ if (relativePath) {
+ p = relativePath;
}
}
@@ -54,7 +48,7 @@ export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLe
p = p
.replace(/^\/Users\/[^/]+/, '~')
.replace(/^\/home\/[^/]+/, '~')
- .replace(/^[A-Z]:\\Users\\[^\\]+/i, '~');
+ .replace(/^[A-Z]:[/\\]Users[/\\][^/\\]+/i, '~');
// 3. If short enough, return as-is
if (p.length <= maxLength) return p;
@@ -84,7 +78,7 @@ function inferHomeDir(projectRoot: string): string | null {
const match =
/^(\/Users\/[^/]+)/.exec(projectRoot) ??
/^(\/home\/[^/]+)/.exec(projectRoot) ??
- /^([A-Z]:\\Users\\[^\\]+)/i.exec(projectRoot);
+ /^([A-Z]:[/\\]Users[/\\][^/\\]+)/i.exec(projectRoot);
return match?.[1] ?? null;
}
@@ -122,11 +116,7 @@ export function formatProjectPath(path: string): string {
}
function isWindowsUserPath(input: string): boolean {
- if (input.length < 10) return false;
- const drive = input.charCodeAt(0);
- const hasDriveLetter =
- ((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':';
- return hasDriveLetter && input.slice(2, 9).toLowerCase() === '\\users\\';
+ return /^[A-Z]:[/\\]Users[/\\]/i.test(input);
}
export function resolveAbsolutePath(filePath: string, projectRoot?: string): string {
diff --git a/src/shared/utils/platformPath.ts b/src/shared/utils/platformPath.ts
index 82f0c0aa..03eacb2f 100644
--- a/src/shared/utils/platformPath.ts
+++ b/src/shared/utils/platformPath.ts
@@ -78,6 +78,7 @@ export function joinPath(base: string, ...segments: string[]): string {
export function isPathPrefix(prefix: string, fullPath: string): boolean {
const p = stripTrailingSeparators(normalizePathForComparison(prefix));
const f = stripTrailingSeparators(normalizePathForComparison(fullPath));
+ if (!p) return false;
if (f === p) return true;
// Root prefixes are special: p already ends with "/" ("/" or "c:/").
if (p === '/') return f.startsWith('/');
@@ -85,6 +86,26 @@ export function isPathPrefix(prefix: string, fullPath: string): boolean {
return f.startsWith(p + '/');
}
+/** Return fullPath relative to prefix when fullPath is inside prefix, preserving fullPath style. */
+export function getRelativePathWithinPrefix(prefix: string, fullPath: string): string | null {
+ const cleanPrefix = stripTrailingSeparators(prefix);
+ if (!isPathPrefix(cleanPrefix, fullPath)) {
+ return null;
+ }
+
+ const normalizedPrefix = stripTrailingSeparators(normalizePathForComparison(cleanPrefix));
+ const normalizedFullPath = stripTrailingSeparators(normalizePathForComparison(fullPath));
+ if (normalizedFullPath === normalizedPrefix) {
+ return '';
+ }
+
+ let relativePath = fullPath.slice(cleanPrefix.length);
+ while (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
+ relativePath = relativePath.slice(1);
+ }
+ return relativePath;
+}
+
/** Get the last segment (filename) from a path. */
export function getBasename(filePath: string): string {
const parts = splitPath(filePath);
diff --git a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
index 147ae8b6..98ddb38f 100644
--- a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
+++ b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts
@@ -129,6 +129,20 @@ describe('recentProjectOpenHistory', () => {
).toBe(0);
});
+ it('collapses Windows drive-case and separator variants', () => {
+ recordRecentProjectOpenPaths(['C:\\Work\\Repo'], 5_000);
+ recordRecentProjectOpenPaths(['c:/work/repo/'], 8_000);
+
+ expect(
+ getRecentProjectLastOpenedAt(
+ makeProject({
+ primaryPath: 'C:/WORK/REPO',
+ associatedPaths: ['C:/WORK/REPO'],
+ })
+ )
+ ).toBe(8_000);
+ });
+
it('does not record generated ephemeral project paths', () => {
recordRecentProjectOpenPaths(
['/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9', '/workspace/opened'],
@@ -152,4 +166,9 @@ describe('recentProjectOpenHistory', () => {
)
).toBe(10_000);
});
+
+ it('ignores blank project paths without throwing', () => {
+ expect(() => recordRecentProjectOpenPaths([' '], 10_000)).not.toThrow();
+ expect(getRecentProjectLastOpenedAt(makeProject({ primaryPath: ' ', associatedPaths: [' '] }))).toBe(0);
+ });
});
diff --git a/test/main/build/electronBuilderDistScript.test.ts b/test/main/build/electronBuilderDistScript.test.ts
index dcb80d48..79ae4377 100644
--- a/test/main/build/electronBuilderDistScript.test.ts
+++ b/test/main/build/electronBuilderDistScript.test.ts
@@ -1,14 +1,10 @@
// @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;
+const { buildElectronBuilderInvocations } = require('../../../scripts/electron-builder/dist-invocations.cjs');
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([
@@ -27,8 +23,6 @@ describe('electron-builder dist wrapper', () => {
});
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: [
@@ -43,8 +37,6 @@ describe('electron-builder dist wrapper', () => {
});
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/editor/FileSearchService.test.ts b/test/main/services/editor/FileSearchService.test.ts
index f9fa4d45..956580d2 100644
--- a/test/main/services/editor/FileSearchService.test.ts
+++ b/test/main/services/editor/FileSearchService.test.ts
@@ -6,6 +6,7 @@ import * as path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('fs/promises', () => ({
+ access: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
stat: vi.fn(),
@@ -36,6 +37,7 @@ describe('FileSearchService', () => {
beforeEach(() => {
vi.resetAllMocks();
+ vi.mocked(fs.access).mockRejectedValue(new Error('not a git repository') as never);
service = new FileSearchService();
});
@@ -173,4 +175,27 @@ describe('FileSearchService', () => {
expect(result.results[0].matches[1].column).toBe(4);
expect(result.results[0].matches[2].column).toBe(8);
});
+
+ it('returns slash-normalized relative paths for quick open on Windows', async () => {
+ vi.mocked(fs.readdir).mockImplementation(async (dirPath: unknown) => {
+ const normalized = path.normalize(String(dirPath));
+ if (normalized === path.normalize(PROJECT_ROOT)) {
+ return [{ name: 'src', isFile: () => false, isDirectory: () => true }] as never;
+ }
+ if (normalized === path.normalize(path.join(PROJECT_ROOT, 'src'))) {
+ return [{ name: 'app.ts', isFile: () => true, isDirectory: () => false }] as never;
+ }
+ return [] as never;
+ });
+
+ const files = await service.listFiles(PROJECT_ROOT);
+
+ expect(files).toEqual([
+ {
+ path: path.join(PROJECT_ROOT, 'src', 'app.ts'),
+ name: 'app.ts',
+ relativePath: 'src/app.ts',
+ },
+ ]);
+ });
});
diff --git a/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
new file mode 100644
index 00000000..bd7e1205
--- /dev/null
+++ b/test/renderer/components/chat/SessionContextPanel/buildDirectoryTree.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildDirectoryTree } from '@renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree';
+
+import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection';
+
+function injection(path: string): ClaudeMdContextInjection {
+ return {
+ id: path,
+ category: 'claude-md',
+ path,
+ source: 'directory',
+ displayName: 'CLAUDE.md',
+ isGlobal: false,
+ estimatedTokens: 12,
+ firstSeenInGroup: 'ai-0',
+ };
+}
+
+describe('buildDirectoryTree Windows paths', () => {
+ it('strips project root case-insensitively for Windows paths with mixed separators', () => {
+ const root = buildDirectoryTree(
+ [injection('c:\\Users\\Alice\\repo\\src\\CLAUDE.md')],
+ 'C:/Users/Alice/Repo'
+ );
+
+ expect(root.children.has('c:')).toBe(false);
+ expect(root.children.get('src')?.children.get('CLAUDE.md')?.path).toBe(
+ 'c:\\Users\\Alice\\repo\\src\\CLAUDE.md'
+ );
+ });
+
+ it('does not strip sibling paths that only share a prefix', () => {
+ const root = buildDirectoryTree(
+ [injection('C:\\Users\\Alice\\Repo2\\CLAUDE.md')],
+ 'C:\\Users\\Alice\\Repo'
+ );
+
+ expect(root.children.get('C:')?.children.get('Users')).toBeDefined();
+ });
+
+ it('falls back to the injection path when project root is empty', () => {
+ const root = buildDirectoryTree([injection('C:\\Users\\Alice\\Repo\\CLAUDE.md')], '');
+
+ expect(root.children.get('C:')?.children.get('Users')).toBeDefined();
+ });
+});
diff --git a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
index 2ed11137..42c8218d 100644
--- a/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
+++ b/test/renderer/components/chat/viewers/MarkdownViewer.test.tsx
@@ -40,7 +40,10 @@ vi.mock('@renderer/components/chat/viewers/FileLink', () => ({
isRelativeUrl: () => false,
}));
-import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
+import {
+ MarkdownViewer,
+ resolveRelativePath,
+} from '@renderer/components/chat/viewers/MarkdownViewer';
describe('MarkdownViewer code blocks', () => {
afterEach(() => {
@@ -89,3 +92,23 @@ describe('MarkdownViewer code blocks', () => {
});
});
});
+
+describe('MarkdownViewer local image path resolution', () => {
+ it('resolves Windows relative image paths against a Windows base directory', () => {
+ expect(resolveRelativePath('.\\images\\plot.png', 'C:\\Repo\\docs')).toBe(
+ 'C:\\Repo\\docs\\images\\plot.png'
+ );
+ });
+
+ it('preserves absolute Windows image paths', () => {
+ expect(resolveRelativePath('C:\\Screens\\plot.png', 'C:\\Repo\\docs')).toBe(
+ 'C:\\Screens\\plot.png'
+ );
+ });
+
+ it('resolves Windows UNC parent image paths without escaping the share root', () => {
+ expect(resolveRelativePath('..\\assets\\plot.png', '\\\\server\\share\\repo\\docs')).toBe(
+ '\\\\server\\share\\repo\\assets\\plot.png'
+ );
+ });
+});
diff --git a/test/renderer/components/fileLink.test.ts b/test/renderer/components/fileLink.test.ts
index 851b3bd2..94edc29a 100644
--- a/test/renderer/components/fileLink.test.ts
+++ b/test/renderer/components/fileLink.test.ts
@@ -121,4 +121,19 @@ describe('resolveFileLinkPath', () => {
resolveFileLinkPath('/Users/belief/dev/projects/your_posts/docs/roadmap.md', PROJECT_PATH)
).toBe('/Users/belief/dev/projects/your_posts/docs/roadmap.md');
});
+
+ it('preserves Windows UNC roots when normalizing path segments', () => {
+ expect(resolveFileLinkPath('\\\\server\\share\\repo\\..\\docs\\roadmap.md', PROJECT_PATH)).toBe(
+ '\\\\server\\share\\docs\\roadmap.md'
+ );
+ expect(resolveFileLinkPath('//server/share/repo/../docs/roadmap.md', PROJECT_PATH)).toBe(
+ '//server/share/docs/roadmap.md'
+ );
+ });
+
+ it('preserves Windows drive roots when normalizing dot segments', () => {
+ expect(resolveFileLinkPath('C:\\repo\\src\\..\\README.md', PROJECT_PATH)).toBe(
+ 'C:\\repo\\README.md'
+ );
+ });
});
diff --git a/test/renderer/hooks/useFileSuggestions.test.ts b/test/renderer/hooks/useFileSuggestions.test.ts
index 1f916937..1b9744cf 100644
--- a/test/renderer/hooks/useFileSuggestions.test.ts
+++ b/test/renderer/hooks/useFileSuggestions.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { filterFileSuggestions, formatFileMentionPath } from '@renderer/hooks/useFileSuggestions';
+import {
+ extractDirectories,
+ filterFileSuggestions,
+ filterFolderSuggestions,
+ formatFileMentionPath,
+} from '@renderer/hooks/useFileSuggestions';
import type { QuickOpenFile } from '@shared/types/editor';
@@ -91,14 +96,42 @@ describe('filterFileSuggestions', () => {
expect(results.map((r) => r.name)).toEqual(['auth.ts', 'database.ts']);
});
+ it('matches Windows backslash queries against normalized relative paths', () => {
+ const results = filterFileSuggestions(FILES, 'services\\auth');
+ expect(results).toHaveLength(1);
+ expect(results[0].relativePath).toBe('src/services/auth.ts');
+ });
+
it('returns results in file list order', () => {
const results = filterFileSuggestions(FILES, '.ts');
expect(results[0].name).toBe('index.ts');
});
it('quotes inserted paths that contain spaces', () => {
- expect(formatFileMentionPath('src/My Component/App.tsx')).toBe(
- '"src/My Component/App.tsx"'
- );
+ expect(formatFileMentionPath('src/My Component/App.tsx')).toBe('"src/My Component/App.tsx"');
+ });
+});
+
+describe('folder suggestions', () => {
+ it('derives stable folders from Windows relative paths', () => {
+ const folders = extractDirectories(
+ [
+ {
+ name: 'auth.ts',
+ relativePath: 'src\\services\\auth.ts',
+ path: 'C:\\Repo\\src\\services\\auth.ts',
+ },
+ ],
+ 'C:\\Repo'
+ );
+
+ expect(folders.map((f) => [f.name, f.relativePath, f.absolutePath])).toEqual([
+ ['src', 'src/', 'C:\\Repo\\src'],
+ ['services', 'src/services/', 'C:\\Repo\\src\\services'],
+ ]);
+
+ const suggestions = filterFolderSuggestions(folders, 'src\\services\\');
+ expect(suggestions).toHaveLength(1);
+ expect(suggestions[0].insertText).toBe('src/services/');
});
});
diff --git a/test/renderer/store/pathResolution.test.ts b/test/renderer/store/pathResolution.test.ts
index 19de8fad..218390dc 100644
--- a/test/renderer/store/pathResolution.test.ts
+++ b/test/renderer/store/pathResolution.test.ts
@@ -13,6 +13,7 @@ describe('resolveFilePath', () => {
it('resolves dot-prefixed relative paths', () => {
expect(resolveFilePath('/repo', './src/app.ts')).toBe('/repo/src/app.ts');
+ expect(resolveFilePath('C:\\repo', '.\\src\\app.ts')).toBe('C:\\repo\\src\\app.ts');
});
it('resolves parent relative paths on unix', () => {
@@ -27,6 +28,11 @@ describe('resolveFilePath', () => {
);
});
+ it('preserves Windows drive-root separators when resolving child paths', () => {
+ expect(resolveFilePath('C:\\', 'src\\app.ts')).toBe('C:\\src\\app.ts');
+ expect(resolveFilePath('C:/', 'src/app.ts')).toBe('C:/src/app.ts');
+ });
+
it('passes through tilde paths as-is', () => {
expect(resolveFilePath('/repo', '~/some/directory')).toBe('~/some/directory');
});
@@ -45,6 +51,24 @@ describe('resolveFilePath', () => {
);
});
+ it('resolves relative paths under Windows UNC roots', () => {
+ expect(resolveFilePath('\\\\server\\share\\repo', 'src\\index.ts')).toBe(
+ '\\\\server\\share\\repo\\src\\index.ts'
+ );
+ expect(resolveFilePath('//server/share/repo', 'src/index.ts')).toBe(
+ '//server/share/repo/src/index.ts'
+ );
+ });
+
+ it('does not resolve parent paths above a Windows UNC share root', () => {
+ expect(resolveFilePath('\\\\server\\share', '..\\outside\\file.ts')).toBe(
+ '\\\\server\\share\\outside\\file.ts'
+ );
+ expect(resolveFilePath('//server/share', '../outside/file.ts')).toBe(
+ '//server/share/outside/file.ts'
+ );
+ });
+
it('does not treat tilde in the middle as special', () => {
expect(resolveFilePath('/repo', 'foo~/bar')).toBe('/repo/foo~/bar');
});
diff --git a/test/renderer/utils/claudeMdTracker.test.ts b/test/renderer/utils/claudeMdTracker.test.ts
index 6e23567e..32f2199d 100644
--- a/test/renderer/utils/claudeMdTracker.test.ts
+++ b/test/renderer/utils/claudeMdTracker.test.ts
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
detectClaudeMdFromFilePath,
+ extractUserMentionPaths,
getDirectory,
getParentDirectory,
+ processSessionClaudeMd,
} from '@renderer/utils/claudeMdTracker';
describe('claudeMdTracker path helpers', () => {
@@ -67,6 +69,13 @@ describe('claudeMdTracker path helpers', () => {
expect(result).toHaveLength(2);
});
+ it('detects CLAUDE.md files for Windows paths with drive-case and separator differences', () => {
+ const result = detectClaudeMdFromFilePath('c:\\Repo\\src\\file.ts', 'C:/repo');
+ expect(result).toContain('c:\\Repo\\src\\CLAUDE.md');
+ expect(result).toContain('c:\\Repo\\CLAUDE.md');
+ expect(result).toHaveLength(2);
+ });
+
it('uses correct separator for generated paths', () => {
const unixResult = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo');
for (const p of unixResult) {
@@ -93,3 +102,97 @@ describe('claudeMdTracker path helpers', () => {
});
});
});
+
+describe('processSessionClaudeMd Windows paths', () => {
+ function aiReadGroup(id: string, turnIndex: number, filePath: string) {
+ return {
+ id,
+ turnIndex,
+ startTime: new Date(0),
+ endTime: new Date(0),
+ durationMs: 0,
+ steps: [
+ {
+ type: 'tool_call',
+ content: {
+ toolName: 'Read',
+ toolInput: { file_path: filePath },
+ },
+ },
+ ],
+ tokens: { input: 1000, output: 0, cached: 0 },
+ summary: {
+ toolCallCount: 1,
+ outputMessageCount: 0,
+ subagentCount: 0,
+ totalDurationMs: 0,
+ totalTokens: 1000,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ status: 'complete',
+ processes: [],
+ chunkId: id,
+ metrics: {},
+ responses: [],
+ } as any;
+ }
+
+ it('dedupes directory CLAUDE.md paths across Windows case and separator differences', () => {
+ const stats = processSessionClaudeMd(
+ [
+ { type: 'ai', group: aiReadGroup('ai-0', 0, 'C:\\Repo\\src\\file.ts') },
+ { type: 'ai', group: aiReadGroup('ai-1', 1, 'c:/repo/src/other.ts') },
+ ],
+ 'C:\\Repo'
+ );
+
+ const firstDirectories = stats
+ .get('ai-0')!
+ .newInjections.filter((injection) => injection.source === 'directory')
+ .map((injection) => injection.path);
+ const secondDirectories = stats
+ .get('ai-1')!
+ .newInjections.filter((injection) => injection.source === 'directory');
+
+ expect(firstDirectories).toEqual(['C:\\Repo\\src\\CLAUDE.md']);
+ expect(secondDirectories).toEqual([]);
+ });
+});
+
+describe('extractUserMentionPaths Windows paths', () => {
+ function userGroupWithPath(path: string) {
+ return {
+ content: {
+ fileReferences: [{ path, raw: `@${path}` }],
+ },
+ } as any;
+ }
+
+ it('resolves Windows current-directory mentions with backslash separators', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('.\\src\\app.ts'), 'C:\\Repo')).toEqual([
+ 'C:\\Repo\\src\\app.ts',
+ ]);
+ });
+
+ it('preserves Windows drive-root separators for relative mentions', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('src\\app.ts'), 'C:\\')).toEqual([
+ 'C:\\src\\app.ts',
+ ]);
+ });
+
+ it('resolves relative mentions under UNC roots without escaping the share root', () => {
+ expect(
+ extractUserMentionPaths(userGroupWithPath('../outside/file.ts'), '//server/share')
+ ).toEqual(['//server/share/outside/file.ts']);
+ expect(
+ extractUserMentionPaths(userGroupWithPath('..\\outside\\file.ts'), '\\\\server\\share')
+ ).toEqual(['\\\\server\\share\\outside\\file.ts']);
+ });
+
+ it('leaves home-relative mentions untouched', () => {
+ expect(extractUserMentionPaths(userGroupWithPath('~\\.claude\\CLAUDE.md'), 'C:\\Repo')).toEqual(
+ ['~\\.claude\\CLAUDE.md']
+ );
+ });
+});
diff --git a/test/renderer/utils/contextTracker.test.ts b/test/renderer/utils/contextTracker.test.ts
new file mode 100644
index 00000000..ae2a04c6
--- /dev/null
+++ b/test/renderer/utils/contextTracker.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+
+import { processSessionContextWithPhases } from '@renderer/utils/contextTracker';
+
+function aiReadGroup(id: string, turnIndex: number, filePath: string) {
+ return {
+ id,
+ turnIndex,
+ startTime: new Date(0),
+ endTime: new Date(0),
+ durationMs: 0,
+ steps: [
+ {
+ type: 'tool_call',
+ content: {
+ toolName: 'Read',
+ toolInput: { file_path: filePath },
+ },
+ },
+ ],
+ tokens: { input: 1000, output: 0, cached: 0 },
+ summary: {
+ toolCallCount: 1,
+ outputMessageCount: 0,
+ subagentCount: 0,
+ totalDurationMs: 0,
+ totalTokens: 1000,
+ outputTokens: 0,
+ cachedTokens: 0,
+ },
+ status: 'complete',
+ processes: [],
+ chunkId: id,
+ metrics: {},
+ responses: [],
+ linkedTools: new Map(),
+ displayItems: [],
+ } as any;
+}
+
+describe('processSessionContextWithPhases Windows paths', () => {
+ it('matches validated directory CLAUDE.md data across drive-case and separator differences', () => {
+ const { statsMap } = processSessionContextWithPhases(
+ [{ type: 'ai', group: aiReadGroup('ai-0', 0, 'c:/repo/src/file.ts') }],
+ 'C:\\Repo',
+ undefined,
+ undefined,
+ {
+ 'C:\\Repo\\src\\CLAUDE.md': {
+ path: 'C:\\Repo\\src\\CLAUDE.md',
+ exists: true,
+ charCount: 492,
+ estimatedTokens: 123,
+ },
+ }
+ );
+
+ const directoryInjection = statsMap
+ .get('ai-0')!
+ .newInjections.find(
+ (injection) => injection.category === 'claude-md' && injection.source === 'directory'
+ );
+
+ expect(directoryInjection?.estimatedTokens).toBe(123);
+ });
+});
diff --git a/test/renderer/utils/pathDisplay.test.ts b/test/renderer/utils/pathDisplay.test.ts
index 20c5976b..f476bf16 100644
--- a/test/renderer/utils/pathDisplay.test.ts
+++ b/test/renderer/utils/pathDisplay.test.ts
@@ -8,25 +8,44 @@ import {
describe('pathDisplay Windows paths', () => {
it('treats lowercase drive paths as absolute', () => {
- expect(resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')).toBe(
- 'c:\\Users\\Alice\\repo\\src\\app.ts'
- );
+ expect(
+ resolveAbsolutePath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\repo')
+ ).toBe('c:\\Users\\Alice\\repo\\src\\app.ts');
});
it('shortens project-root relative paths case-insensitively on Windows', () => {
- expect(shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')).toBe(
+ expect(
+ shortenDisplayPath('c:\\Users\\Alice\\repo\\src\\app.ts', 'C:\\Users\\Alice\\Repo')
+ ).toBe('src\\app.ts');
+ });
+
+ it('shortens mixed-separator Windows paths without treating siblings as children', () => {
+ expect(shortenDisplayPath('c:\\Users\\Alice\\Repo\\src\\app.ts', 'C:/Users/Alice/repo')).toBe(
'src\\app.ts'
);
+ expect(
+ shortenDisplayPath('C:\\Users\\Alice\\Repo2\\src\\app.ts', 'C:\\Users\\Alice\\Repo')
+ ).toBe('~\\Repo2\\src\\app.ts');
});
it('formats lowercase Windows user paths with a home marker', () => {
expect(formatProjectPath('c:\\users\\Alice\\repo')).toBe('~/repo');
+ expect(formatProjectPath('C:/Users/Alice/repo')).toBe('~/repo');
});
it('resolves home paths from lowercase Windows user roots', () => {
expect(resolveAbsolutePath('~/repo/src/app.ts', 'c:\\users\\Alice\\workspace')).toBe(
'c:\\users\\Alice\\repo\\src\\app.ts'
);
+ expect(resolveAbsolutePath('~/repo/src/app.ts', 'C:/Users/Alice/workspace')).toBe(
+ 'C:/Users/Alice/repo/src/app.ts'
+ );
+ });
+
+ it('shortens forward-slash Windows user paths with a home marker', () => {
+ expect(shortenDisplayPath('C:/Users/Alice/repo/src/app.ts', undefined, 80)).toBe(
+ '~/repo/src/app.ts'
+ );
});
it('resolves relative paths using the project root separator', () => {
diff --git a/test/shared/utils/platformPath.test.ts b/test/shared/utils/platformPath.test.ts
new file mode 100644
index 00000000..19d168cc
--- /dev/null
+++ b/test/shared/utils/platformPath.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+
+import { getRelativePathWithinPrefix, isPathPrefix } from '../../../src/shared/utils/platformPath';
+
+describe('platformPath Windows containment', () => {
+ it('matches Windows drive paths case-insensitively and preserves child path style', () => {
+ expect(isPathPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts')).toBe(true);
+ expect(
+ getRelativePathWithinPrefix('C:/Users/Alice/Repo', 'c:\\Users\\Alice\\repo\\src\\app.ts')
+ ).toBe('src\\app.ts');
+ });
+
+ it('matches UNC paths with mixed separators', () => {
+ expect(
+ getRelativePathWithinPrefix('\\\\server\\share\\Repo', '//server/share/repo/src/app.ts')
+ ).toBe('src/app.ts');
+ });
+
+ it('rejects sibling paths that only share the same text prefix', () => {
+ expect(
+ getRelativePathWithinPrefix('C:\\Users\\Alice\\Repo', 'C:\\Users\\Alice\\Repo2\\x.ts')
+ ).toBe(null);
+ });
+
+ it('keeps POSIX paths case-sensitive', () => {
+ expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/Repo/src/app.ts')).toBe(
+ 'src/app.ts'
+ );
+ expect(getRelativePathWithinPrefix('/Users/Alice/Repo', '/Users/Alice/repo/src/app.ts')).toBe(
+ null
+ );
+ });
+
+ it('does not treat an empty prefix as the root of absolute paths', () => {
+ expect(isPathPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(false);
+ expect(getRelativePathWithinPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(null);
+ });
+});
From cb97b97e75ebf96281ce4ee58d7a63f281da16d4 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sat, 16 May 2026 17:43:25 +0300
Subject: [PATCH 11/11] fix: align project path option metadata
---
src/renderer/components/team/dialogs/projectPathOptions.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts
index 6cdae4b0..496ca3e9 100644
--- a/src/renderer/components/team/dialogs/projectPathOptions.ts
+++ b/src/renderer/components/team/dialogs/projectPathOptions.ts
@@ -13,7 +13,7 @@ export interface ProjectPathProject extends Project {
filesystemState?: DashboardRecentProjectFilesystemState;
}
-export interface ProjectPathOptionMeta {
+export interface ProjectPathOptionMeta extends Record {
discoverySource?: DashboardRecentProjectSource;
filesystemState?: DashboardRecentProjectFilesystemState;
}