diff --git a/common/__tests__/project-resolution.test.js b/common/__tests__/project-resolution.test.js new file mode 100644 index 000000000..6e26c92be --- /dev/null +++ b/common/__tests__/project-resolution.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test'; +import { + parseProjectResolutionResponse, + projectTargetKey, +} from '../project-resolution.ts'; + +const CHAT_ID = '1783725900000800'; + +describe('project resolution contract', () => { + it('round-trips both target and resolution variants', () => { + expect(parseProjectResolutionResponse({ + target: { kind: 'chat', chatId: CHAT_ID, projectPath: '/workspace/project' }, + resolution: { kind: 'available', effectiveProjectKey: '/real/project' }, + })).toEqual({ + target: { kind: 'chat', chatId: CHAT_ID, projectPath: '/workspace/project' }, + resolution: { kind: 'available', effectiveProjectKey: '/real/project' }, + }); + expect(parseProjectResolutionResponse({ + target: { kind: 'path', projectPath: '/workspace/missing' }, + resolution: { kind: 'unavailable', reason: 'not-found' }, + })).toEqual({ + target: { kind: 'path', projectPath: '/workspace/missing' }, + resolution: { kind: 'unavailable', reason: 'not-found' }, + }); + }); + + it('rejects malformed and open-ended payloads', () => { + for (const value of [ + null, + {}, + { + target: { kind: 'chat', chatId: 'chat', projectPath: '/workspace/project' }, + resolution: { kind: 'available', effectiveProjectKey: '/real/project' }, + }, + { + target: { kind: 'path', projectPath: ' ' }, + resolution: { kind: 'unavailable', reason: 'missing' }, + }, + { + target: { kind: 'path', projectPath: '/workspace/project', extra: true }, + resolution: { kind: 'available', effectiveProjectKey: '/real/project' }, + }, + { + target: { kind: 'path', projectPath: '/workspace/project' }, + resolution: { kind: 'available', effectiveProjectKey: '', reason: 'not-found' }, + }, + ]) { + expect(parseProjectResolutionResponse(value)).toBeNull(); + } + }); + + it('keys declared chat and raw-path targets separately', () => { + expect(projectTargetKey({ kind: 'chat', chatId: CHAT_ID, projectPath: '/project' })) + .not.toBe(projectTargetKey({ kind: 'path', projectPath: '/project' })); + }); +}); diff --git a/common/chat-command-contracts.ts b/common/chat-command-contracts.ts index 45bc4ec4b..3a3da9c72 100644 --- a/common/chat-command-contracts.ts +++ b/common/chat-command-contracts.ts @@ -106,6 +106,7 @@ export type CommandErrorCode = Extract< | 'PROJECT_PATH_NOT_FOUND' | 'PROJECT_PATH_NOT_DIRECTORY' | 'PROJECT_PATH_NATIVE_PATH_UNRESOLVED' + | 'PROJECT_UNAVAILABLE' | 'SESSION_BUSY' | 'REQUEST_NOT_FOUND' | 'SERVER_SHUTTING_DOWN' @@ -474,7 +475,6 @@ export interface ProjectPathPatchResponse { projectPath: string; effectiveProjectKey: string; previousProjectPath: string; - previousEffectiveProjectKey: string | null; } export interface RunningChatsResponse { diff --git a/common/chat-list.ts b/common/chat-list.ts index db6777290..cbf095840 100644 --- a/common/chat-list.ts +++ b/common/chat-list.ts @@ -18,7 +18,6 @@ export interface ChatListEntry { agentSettings: AgentSettingsEnvelope; title: string; projectPath: string; - effectiveProjectKey: string; orderGroup: ChatOrderGroup; tags: string[]; activity: { diff --git a/common/error-codes.ts b/common/error-codes.ts index e1e23b735..f91aa598c 100644 --- a/common/error-codes.ts +++ b/common/error-codes.ts @@ -69,6 +69,8 @@ export const ERROR_CODES = [ 'PROJECT_PATH_OUTSIDE_BASE', 'PROJECT_PATH_NOT_FOUND', 'PROJECT_PATH_NOT_DIRECTORY', + 'PROJECT_PATH_CHANGED', + 'PROJECT_UNAVAILABLE', 'PROJECT_PATH_NATIVE_PATH_UNRESOLVED', 'FOLDER_ALREADY_EXISTS', 'SAVED_SEARCH_ALREADY_EXISTS', diff --git a/common/package.json b/common/package.json index 18d61c443..2e74d3b77 100644 --- a/common/package.json +++ b/common/package.json @@ -49,6 +49,7 @@ "./models": "./models.ts", "./native-session-lookup": "./native-session-lookup.ts", "./prompt-refinement": "./prompt-refinement.ts", + "./project-resolution": "./project-resolution.ts", "./preamble-prefix": "./preamble-prefix.ts", "./preambles": "./preambles.ts", "./queue-state": "./queue-state.ts", diff --git a/common/project-resolution.ts b/common/project-resolution.ts new file mode 100644 index 000000000..9edf55649 --- /dev/null +++ b/common/project-resolution.ts @@ -0,0 +1,83 @@ +import { parseChatId } from './chat-id.js'; +import { isRecord } from './json.js'; + +export type ProjectTarget = + | { readonly kind: 'chat'; readonly chatId: string; readonly projectPath: string } + | { readonly kind: 'path'; readonly projectPath: string }; + +export const PROJECT_UNAVAILABLE_REASONS = [ + 'not-found', + 'not-a-directory', + 'outside-base', + 'permission-denied', +] as const; + +export type ProjectUnavailableReason = (typeof PROJECT_UNAVAILABLE_REASONS)[number]; + +export type ProjectResolution = + | { readonly kind: 'available'; readonly effectiveProjectKey: string } + | { readonly kind: 'unavailable'; readonly reason: ProjectUnavailableReason }; + +export interface ProjectResolutionResponse { + readonly target: ProjectTarget; + readonly resolution: ProjectResolution; +} + +export function projectTargetKey(target: ProjectTarget): string { + return target.kind === 'chat' + ? JSON.stringify(['chat', target.chatId, target.projectPath]) + : JSON.stringify(['path', target.projectPath]); +} + +export function isProjectUnavailableReason(value: unknown): value is ProjectUnavailableReason { + return typeof value === 'string' + && PROJECT_UNAVAILABLE_REASONS.some((reason) => reason === value); +} + +export function parseProjectResolutionResponse(value: unknown): ProjectResolutionResponse | null { + if (!isRecord(value) || !hasExactKeys(value, ['target', 'resolution'])) return null; + const target = parseProjectTarget(value.target); + const resolution = parseProjectResolution(value.resolution); + return target && resolution ? { target, resolution } : null; +} + +function parseProjectTarget(value: unknown): ProjectTarget | null { + if (!isRecord(value) || typeof value.projectPath !== 'string' || !value.projectPath.trim()) { + return null; + } + if (value.kind === 'path' && hasExactKeys(value, ['kind', 'projectPath'])) { + return { kind: 'path', projectPath: value.projectPath }; + } + if (value.kind !== 'chat' || !hasExactKeys(value, ['kind', 'chatId', 'projectPath'])) return null; + try { + return { kind: 'chat', chatId: parseChatId(value.chatId), projectPath: value.projectPath }; + } catch { + return null; + } +} + +function parseProjectResolution(value: unknown): ProjectResolution | null { + if (!isRecord(value)) return null; + if ( + value.kind === 'available' + && hasExactKeys(value, ['kind', 'effectiveProjectKey']) + && typeof value.effectiveProjectKey === 'string' + && value.effectiveProjectKey.trim() + ) { + return { kind: 'available', effectiveProjectKey: value.effectiveProjectKey }; + } + if ( + value.kind === 'unavailable' + && hasExactKeys(value, ['kind', 'reason']) + && isProjectUnavailableReason(value.reason) + ) { + return { kind: 'unavailable', reason: value.reason }; + } + return null; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} diff --git a/common/ws-events.ts b/common/ws-events.ts index 7808f0005..2a0f78ecf 100644 --- a/common/ws-events.ts +++ b/common/ws-events.ts @@ -149,7 +149,6 @@ export class ChatProjectPathUpdatedMessage { public projectPath: string, public effectiveProjectKey: string, public previousProjectPath: string, - public previousEffectiveProjectKey: string | null, ) {} } @@ -158,7 +157,6 @@ export interface ChatProjectPathUpdatedPayload { projectPath: string; effectiveProjectKey: string; previousProjectPath: string; - previousEffectiveProjectKey: string | null; } export class ChatSessionStoppedMessage { @@ -673,19 +671,12 @@ export function parseServerWsMessage( const projectPath = requiredStr(data.projectPath); const effectiveProjectKey = requiredStr(data.effectiveProjectKey); const previousProjectPath = requiredStr(data.previousProjectPath); - const previousEffectiveProjectKey = data.previousEffectiveProjectKey; - if ( - previousEffectiveProjectKey !== null && - typeof previousEffectiveProjectKey !== 'string' - ) - return null; return chatId && projectPath && effectiveProjectKey && previousProjectPath ? new ChatProjectPathUpdatedMessage( chatId, projectPath, effectiveProjectKey, previousProjectPath, - previousEffectiveProjectKey, ) : null; } diff --git a/integration-tests/tests/e2e/on-demand-project-resolution.test.ts b/integration-tests/tests/e2e/on-demand-project-resolution.test.ts new file mode 100644 index 000000000..de6f60c13 --- /dev/null +++ b/integration-tests/tests/e2e/on-demand-project-resolution.test.ts @@ -0,0 +1,66 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, test } from 'bun:test'; +import { withE2eFixture } from '../../support/e2e-fixture.js'; +import { seedLocalSettings } from '../../support/local-settings-seed.js'; +import { SpaDriver } from '../../support/spa-driver.js'; + +describe('Lightpanda on-demand project resolution', () => { + test('keeps chat history and drafts quiet until a project surface is presented', async () => { + await withE2eFixture('on-demand-project-resolution', async (fixture) => { + await fixture.page.evaluateOnNewDocument(seedLocalSettings, { showQuickCommitTray: false }); + const projectPath = join(fixture.integration.dirs.project, 'missing-project'); + await mkdir(projectPath); + const chatId = fixture.integration.newChatId(); + const started = await fixture.integration.client.startDirectChat({ + chatId, + content: 'browser unavailable project seed', + projectPath, + agent: fixture.integration.directAgents.openAi, + }); + await fixture.integration.client.waitForTurnTerminal(chatId, started.turnId); + await rm(projectPath, { recursive: true }); + + const resolutionRequests: string[] = []; + fixture.page.on('request', (request) => { + if (new URL(request.url()).pathname === '/api/v1/projects/resolve') { + resolutionRequests.push(request.url()); + } + }); + const app = new SpaDriver(fixture.page, fixture.integration); + await app.setViewport(390, 844); + await app.openChat(chatId); + await fixture.waitForSpaWebSocket(); + await fixture.page.waitForFunction( + () => document.body.textContent?.includes('browser unavailable project seed') === true, + { timeout: 20_000 }, + ); + await app.fill('textarea[placeholder="Reply..."]', 'draft remains editable'); + await fixture.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + expect(resolutionRequests).toEqual([]); + + await app.clickButton('Files'); + await fixture.page.waitForFunction( + () => document.body.textContent?.includes('Project folder unavailable') === true, + { timeout: 20_000 }, + ); + expect(resolutionRequests).toHaveLength(1); + expect(await fixture.page.$eval( + 'textarea[placeholder="Reply..."]', + (element) => (element as HTMLTextAreaElement).value, + )).toBe('draft remains editable'); + + await mkdir(projectPath); + await app.clickButton('Retry'); + await fixture.page.waitForFunction( + (expectedPath) => document + .querySelector('[data-file-tree-breadcrumbs] [aria-current="location"]') + ?.getAttribute('title') === expectedPath, + { timeout: 20_000 }, + projectPath, + ); + expect(resolutionRequests).toHaveLength(2); + fixture.assertNoBrowserErrors(); + }); + }, 30_000); +}); diff --git a/integration-tests/tests/server/chat-sort.test.ts b/integration-tests/tests/server/chat-sort.test.ts index 93287fc11..d8c618464 100644 --- a/integration-tests/tests/server/chat-sort.test.ts +++ b/integration-tests/tests/server/chat-sort.test.ts @@ -68,7 +68,7 @@ test('chat sort presets atomically reorder every persisted group and survive res beforeStart: () => rename(secondaryProject, unavailableProject), }); let listed = await fixture.client.listChats(); - expect(listed.sessions.some((chat) => chat.id === hiddenChatId)).toBe(false); + expect(listed.sessions.some((chat) => chat.id === hiddenChatId)).toBe(true); const createdCursor = fixture.client.markEvents(); expect(await fixture.client.sortChatOrder({ sortKey: 'created' })).toEqual({ diff --git a/integration-tests/tests/server/garcon-cli.test.ts b/integration-tests/tests/server/garcon-cli.test.ts index beebbc45c..19062ff09 100644 --- a/integration-tests/tests/server/garcon-cli.test.ts +++ b/integration-tests/tests/server/garcon-cli.test.ts @@ -758,13 +758,22 @@ describe('garcon-cli', () => { await fs.rm(nestedProject, { recursive: true, force: true }); const status = await runCli(controlArguments(fixture, [ - 'status', chatId!, '--messages', '0', '--json', + 'status', chatId!, '--json', ])); expect(status.exitCode).toBe(0); - expect(JSON.parse(status.stdout)).toMatchObject({ + const snapshot = JSON.parse(status.stdout); + expect(snapshot).toMatchObject({ chat: { id: chatId, projectPath: nestedProject }, - transcript: { availability: 'not-requested' }, + transcript: { availability: 'available' }, }); + expect(userContents(snapshot.transcript.messages)).toContain('cli-removed-project'); + + const rejected = await runCli(controlArguments(fixture, [ + 'send-async', chatId!, 'cli-unavailable-follow-up', + ])); + expect(rejected.exitCode).toBe(3); + expect(rejected.stdout).toBe(''); + expect(rejected.stderr).toContain('submission: Project folder unavailable (not-found)'); }, { namedWorkspace: WORKSPACE }); }); diff --git a/integration-tests/tests/server/on-demand-project-resolution.test.ts b/integration-tests/tests/server/on-demand-project-resolution.test.ts new file mode 100644 index 000000000..1e1982c17 --- /dev/null +++ b/integration-tests/tests/server/on-demand-project-resolution.test.ts @@ -0,0 +1,198 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, test } from 'bun:test'; +import type { TranscriptExportResponse } from '../../../common/chat-export-contracts.js'; +import type { ProjectResolutionResponse } from '../../../common/project-resolution.js'; +import type { + AgentRunFinishedMessage, + ChatExecutionControlUpdatedMessage, + ChatOperationalNoticeMessage, +} from '../../../common/ws-events.js'; +import { userContents } from '../../support/chat-assertions.js'; +import type { GarconTestClient } from '../../support/garcon-client.js'; +import { withIntegrationFixture } from '../../support/integration-fixture.js'; + +describe('on-demand project resolution', () => { + test('keeps unavailable chats readable and rejects new work until the folder returns', async () => { + await withIntegrationFixture('on-demand-project-resolution', async (fixture) => { + await fixture.client.updateSettings({ features: { transcriptSearch: { enabled: true } } }); + const projectPath = join(fixture.dirs.project, 'movable-project'); + await mkdir(projectPath); + const chatId = fixture.newChatId(); + const marker = 'synthetic unavailable project marker'; + const started = await fixture.client.startDirectChat({ + chatId, + content: marker, + projectPath, + agent: fixture.directAgents.openAi, + }); + await fixture.client.waitForTurnTerminal(chatId, started.turnId); + const before = await fixture.client.getMessages(chatId); + await fixture.client.waitForChatSearch( + { query: 'synthetic unavailable', chatIds: [chatId] }, + (response) => response.results.some((result) => result.chatId === chatId), + ); + await fixture.client.put('/api/v1/chats/last-selected', { chatId }); + const requestCount = fixture.fakeProviders.openAi.requests().length; + + await rm(projectPath, { recursive: true }); + + const listed = await fixture.client.listChats(); + expect(listed.sessions.find((chat) => chat.id === chatId)).toMatchObject({ projectPath }); + expect(listed.lastSelectedChatId).toBe(chatId); + const history = await fixture.client.getMessages(chatId); + expect(history.transcriptViewId).toBe(before.transcriptViewId); + expect(userContents(history.messages)).toEqual([marker]); + const search = await fixture.client.searchChats({ + query: 'synthetic unavailable', + chatIds: [chatId], + }); + expect(search.results.map((result) => result.chatId)).toEqual([chatId]); + const exported = await fixture.client.get( + `/api/v1/chats/export?chatId=${chatId}`, + ); + expect(exported.transcriptViewId).toBe(before.transcriptViewId); + expect(exported.document).toContain(marker); + expect(await resolveChatProject(fixture.client, chatId, projectPath)).toEqual({ + target: { kind: 'chat', chatId, projectPath }, + resolution: { kind: 'unavailable', reason: 'not-found' }, + }); + + const retryRequest = fixture.client.directRunRequest({ + chatId, + content: 'run after project restore', + agent: fixture.directAgents.openAi, + clientRequestId: 'request-project-restore', + clientMessageId: 'message-project-restore', + }); + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(fixture.client.runChat({ + ...retryRequest, + transcriptViewId: before.transcriptViewId, + })).rejects.toMatchObject({ + status: 409, + body: { errorCode: 'PROJECT_UNAVAILABLE', retryable: false }, + }); + } + await expect(fixture.client.enqueue({ + chatId, + content: 'queue while unavailable', + clientRequestId: 'request-queue-unavailable', + clientMessageId: 'message-queue-unavailable', + transcriptViewId: before.transcriptViewId, + })).rejects.toMatchObject({ + status: 409, + body: { errorCode: 'PROJECT_UNAVAILABLE', retryable: false }, + }); + expect(fixture.fakeProviders.openAi.requests()).toHaveLength(requestCount); + expect((await fixture.client.getMessages(chatId)).messages).toEqual(before.messages); + + await mkdir(projectPath); + expect(await resolveChatProject(fixture.client, chatId, projectPath)).toMatchObject({ + resolution: { kind: 'available', effectiveProjectKey: projectPath }, + }); + const resumed = await fixture.client.runChat({ + ...retryRequest, + transcriptViewId: before.transcriptViewId, + }); + await fixture.client.waitForTurnTerminal(chatId, resumed.turnId); + expect((await fixture.client.getMessages(chatId)).transcriptViewId).toBe( + before.transcriptViewId, + ); + expect(fixture.fakeProviders.openAi.requests()).toHaveLength(requestCount + 1); + }); + }, 30_000); + + test('pauses before dequeue and clears pending work after a successful relocation', async () => { + await withIntegrationFixture('on-demand-project-queue', async (fixture) => { + const sourceProject = join(fixture.dirs.project, 'queue-source'); + const destinationProject = join(fixture.dirs.project, 'queue-destination'); + await mkdir(sourceProject); + await mkdir(destinationProject); + const chatId = fixture.newChatId(); + const heldSeed = fixture.fakeProviders.openAi.holdNext({ lastUserText: 'queue seed' }); + const started = await fixture.client.startDirectChat({ + chatId, + content: 'queue seed', + projectPath: sourceProject, + agent: fixture.directAgents.openAi, + }); + await heldSeed.received; + await fixture.client.enqueueNew(chatId, 'preserved queued work'); + const initialPause = await fixture.client.pauseQueue(chatId); + if (!initialPause.control.queue.pause) throw new Error('Queue did not enter a manual pause.'); + heldSeed.releaseEcho(); + await fixture.client.waitForTurnTerminal(chatId, started.turnId); + const requestCount = fixture.fakeProviders.openAi.requests().length; + await rm(sourceProject, { recursive: true }); + + const pauseCursor = fixture.client.markEvents(); + await fixture.client.resumeQueue(chatId, initialPause.control.queue.pause.id); + const paused = await fixture.client.waitForEvent( + (event): event is ChatExecutionControlUpdatedMessage => ( + event.type === 'chat-execution-control-updated' + && event.chatId === chatId + && event.control.queue.pause?.kind === 'manual' + ), + 'project-unavailable queue pause', + { afterIndex: pauseCursor }, + ); + expect(paused.control.queue.entries.map((entry) => entry.content)).toEqual([ + 'preserved queued work', + ]); + await fixture.client.waitForEvent( + (event): event is ChatOperationalNoticeMessage => ( + event.type === 'chat-operational-notice' + && event.chatId === chatId + && event.noticeType === 'warning' + ), + 'project-unavailable operational notice', + { afterIndex: pauseCursor }, + ); + expect(fixture.fakeProviders.openAi.requests()).toHaveLength(requestCount); + + const updated = await fixture.client.updateProjectPath({ + chatId, + projectPath: destinationProject, + }); + expect(updated).toMatchObject({ + projectPath: destinationProject, + effectiveProjectKey: destinationProject, + previousProjectPath: sourceProject, + }); + expect((await fixture.client.getExecutionControl(chatId)).queue).toMatchObject({ + entries: [], + pause: null, + }); + expect(fixture.fakeProviders.openAi.requests()).toHaveLength(requestCount); + + const turnCursor = fixture.client.markEvents(); + const fresh = await fixture.client.runDirectChat({ + chatId, + content: 'fresh work in destination', + agent: fixture.directAgents.openAi, + }); + await fixture.client.waitForEvent( + (event): event is AgentRunFinishedMessage => ( + event.type === 'agent-run-finished' + && event.chatId === chatId + && event.turnId === fresh.turnId + ), + 'fresh relocated turn', + { afterIndex: turnCursor }, + ); + expect(fixture.fakeProviders.openAi.requests().at(-1)?.lastUserText).toBe( + 'fresh work in destination', + ); + }); + }, 30_000); +}); + +async function resolveChatProject( + client: GarconTestClient, + chatId: string, + projectPath: string, +): Promise { + const query = new URLSearchParams({ chatId, expectedProjectPath: projectPath }); + return client.get(`/api/v1/projects/resolve?${query}`); +} diff --git a/server-agents/codex/src/agents/codex/app-server/__tests__/app-server.test.js b/server-agents/codex/src/agents/codex/app-server/__tests__/app-server.test.js index 8b9d97d99..3c7e00cf4 100644 --- a/server-agents/codex/src/agents/codex/app-server/__tests__/app-server.test.js +++ b/server-agents/codex/src/agents/codex/app-server/__tests__/app-server.test.js @@ -2033,6 +2033,14 @@ describe('CodexAppServerRuntime', () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); + function availableQueueOptions() { + return { + projectAdmission: { assertAvailable: async () => undefined }, + unsettledQueueReceiptKeys: () => new Set(), + appendControlReceipt: () => {}, + }; + } + function createActiveGoalQueue(provider, codexGoalCommand, operation) { return new ChatExecutionCoordinator( tmpDir, @@ -2050,6 +2058,7 @@ describe('CodexAppServerRuntime', () => { isChatRunning: () => provider.isRunning('thread-1'), }, { + hasMatchingInput: async () => false, admitInput: async () => ({ inserted: true }), admitQueuedInput: () => ({ inserted: true }), discardPreparedInput: () => {}, @@ -2063,6 +2072,7 @@ describe('CodexAppServerRuntime', () => { }), () => true, new InMemoryChatExecutionControlRepository('server-instance-test'), + availableQueueOptions(), ); } @@ -5249,6 +5259,7 @@ describe('CodexAppServerRuntime', () => { isChatRunning: () => provider.isRunning('thread-1'), }, { + hasMatchingInput: async () => false, admitInput: async () => { registered = true; return { inserted: true }; @@ -5265,6 +5276,7 @@ describe('CodexAppServerRuntime', () => { }), () => true, new InMemoryChatExecutionControlRepository('server-instance-test'), + availableQueueOptions(), ); const result = await queue.deliverGoalControlInput('chat-1', 'Steer from the queue', { diff --git a/server/__tests__/architecture-budgets.test.js b/server/__tests__/architecture-budgets.test.js index edaaff926..b86d4ecda 100644 --- a/server/__tests__/architecture-budgets.test.js +++ b/server/__tests__/architecture-budgets.test.js @@ -10,8 +10,9 @@ const MAX_LINES = 1000; // Keeps command and execution orchestration from regrowing persistence or // provider concerns now owned by the ledger and integration boundary. The // provider-neutral control routes include one-shot discovery delivery and one -// bounded private lane sharing the existing queue drainer. -const EXECUTION_FOOTPRINT_BUDGET = 8250; +// bounded private lane sharing the existing queue drainer. Fresh project +// admission also gates direct work, queue creation, and pre-dequeue dispatch. +const EXECUTION_FOOTPRINT_BUDGET = 8415; const GRANDFATHER = { 'server/git/diff-engine.ts': 1575, diff --git a/server/__tests__/server-event-wiring.test.js b/server/__tests__/server-event-wiring.test.js index 8d3272c89..56f7e3fda 100644 --- a/server/__tests__/server-event-wiring.test.js +++ b/server/__tests__/server-event-wiring.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, mock } from 'bun:test'; import { AssistantMessage } from '../../common/chat-types.js'; import { emptyStoredChatExecutionControl } from '../chat-execution/control-state.ts'; import { ChatTransientFeedStore } from '../chats/chat-transient-feed.js'; +import { ProjectUnavailableError } from '../lib/domain-error.ts'; import { wireServerEvents } from '../server-event-wiring.js'; import { attachNativeMessageSource, @@ -37,6 +38,7 @@ function createFixture(overrides = {}) { onProcessingInvalidated: mock((callback) => { queue.processing = callback; }), onSessionStopped: mock((callback) => { queue.stopped = callback; }), onTurnFailed: mock((callback) => { queue.failed = callback; }), + onProjectUnavailable: mock((callback) => { queue.projectUnavailable = callback; }), onTurnSettled: mock((callback) => { queue.settled = callback; }), getQueuedTurnFinalization: mock(() => null), onAgentTurnTerminal: mock(async () => undefined), @@ -555,6 +557,22 @@ describe('server event wiring', () => { expect(fixture.metadata.updateFromAppendedMessages).not.toHaveBeenCalled(); }); + it('publishes unavailable-project queue warnings as operational notices', async () => { + const fixture = createFixture(); + const unavailable = new ProjectUnavailableError('/workspace/missing', 'not-found'); + + fixture.queue.projectUnavailable('chat-1', unavailable); + await fixture.wiring.waitForIdle(); + + expect(fixture.published).toEqual([expect.objectContaining({ + type: 'chat-operational-notice', + chatId: 'chat-1', + noticeType: 'warning', + content: unavailable.message, + })]); + expect(fixture.metadata.updateFromAppendedMessages).not.toHaveBeenCalled(); + }); + it('reports task failures through the shutdown drain', async () => { const failure = new Error('command ledger unavailable'); const fixture = createFixture({ diff --git a/server/agents/registry.ts b/server/agents/registry.ts index b3714c619..1132063c6 100644 --- a/server/agents/registry.ts +++ b/server/agents/registry.ts @@ -85,6 +85,11 @@ export interface AgentRegistryServiceContract { requiresStrictModelDiscovery(agentId: string): boolean; isAgentSessionRunning(agentId: string, agentSessionId: string | null | undefined): boolean; currentTranscriptViewId(chatId: string): Promise; + hasMatchingInput( + chatId: string, + message: UserMessage, + options: UserInputAdmissionOptions, + ): boolean; publishSessionFact(chatId: string, session: StartedAgentSession): void; resendCandidates(chatId: string): readonly import('../../common/chat-view.js').ResendCandidate[]; captureSteerTarget(chatId: string): AgentSteerTarget | null; @@ -539,6 +544,30 @@ export class AgentRegistry implements AgentRegistryServiceContract { }); } + hasMatchingInput( + chatId: string, + message: UserMessage, + options: UserInputAdmissionOptions, + ): boolean { + if (!this.#registry.getChat(chatId)) return false; + const current = this.#ledger.existingCurrentView(chatId); + if (!current) return false; + try { + return this.#ledger.hasMatchingInputSubmission({ + chatId, + viewId: options.transcriptViewId + ? transcriptViewId(options.transcriptViewId) + : current.viewId, + message, + attachments: inputAttachments(options), + clientMessageId: options.clientMessageId ?? null, + steer: options.commandType === 'steer', + }); + } catch (error) { + throw mapInputSubmissionError(error); + } + } + admitQueuedInput( chatId: string, message: UserMessage, @@ -568,12 +597,7 @@ export class AgentRegistry implements AgentRegistryServiceContract { : false; const boundary = pending && !alreadyConsumed ? pending : null; const viewId = options.transcriptViewId ? transcriptViewId(options.transcriptViewId) : currentViewId; - const attachments = (options.images ?? []).map((image) => ({ - kind: 'image' as const, - data: image.data, - name: image.name ?? null, - mimeType: image.mimeType ?? 'application/octet-stream', - })); + const attachments = inputAttachments(options); const slashLeading = message.content.trimStart().startsWith('/'); let composition; try { @@ -628,13 +652,7 @@ export class AgentRegistry implements AgentRegistryServiceContract { : {}), }); } catch (error) { - if (error instanceof StaleTranscriptViewError) { - throw new DomainError('STALE_TRANSCRIPT_VIEW', error.message, 409, false, { cause: error }); - } - if (error instanceof SubmissionConflictError) { - throw new DomainError('IDEMPOTENCY_CONFLICT', error.message, 409, false, { cause: error }); - } - throw error; + throw mapInputSubmissionError(error); } if (pending && (alreadyConsumed || composition.inserted)) { this.#clearProvenPendingBoundary(chatId, pending); @@ -693,6 +711,25 @@ export class AgentRegistry implements AgentRegistryServiceContract { getAgentCatalogEntries() { return this.#catalog.getAgentCatalogEntries(); } } +function inputAttachments(options: UserInputAdmissionOptions) { + return (options.images ?? []).map((image) => ({ + kind: 'image' as const, + data: image.data, + name: image.name ?? null, + mimeType: image.mimeType ?? 'application/octet-stream', + })); +} + +function mapInputSubmissionError(error: unknown): unknown { + if (error instanceof StaleTranscriptViewError) { + return new DomainError('STALE_TRANSCRIPT_VIEW', error.message, 409, false, { cause: error }); + } + if (error instanceof SubmissionConflictError) { + return new DomainError('IDEMPOTENCY_CONFLICT', error.message, 409, false, { cause: error }); + } + return error; +} + function messageText(message: ChatMessage): string { return 'content' in message && typeof message.content === 'string' ? message.content diff --git a/server/chat-execution/__tests__/accepted-input-handler.test.js b/server/chat-execution/__tests__/accepted-input-handler.test.js index f145de885..9bca17225 100644 --- a/server/chat-execution/__tests__/accepted-input-handler.test.js +++ b/server/chat-execution/__tests__/accepted-input-handler.test.js @@ -4,6 +4,7 @@ import { DuplicateGoalControlInputError } from '../goal-control-delivery.ts'; import { DomainError, GoalControlDeliveryError, + ProjectUnavailableError, QueueEntrySteerError, SteerDeliveryError, } from '../../lib/domain-error.ts'; @@ -109,6 +110,7 @@ function scaffold(overrides = {}) { requestDrain: mock(() => undefined), reserveDirect: mock(() => reservation), checkpoint: mock(() => undefined), + hasMatchingInput: mock(async () => false), admitInput: mock(async () => true), discardPreparedInput: mock(() => undefined), releaseDirect: mock(async () => undefined), @@ -116,6 +118,7 @@ function scaffold(overrides = {}) { trackDispatch: mock(() => undefined), deliverGoalControl: mock(async () => false), steer: mock(async () => ({ turnId: 'turn-1' })), + assertProjectAvailable: mock(async () => undefined), ...overrides, }; const handler = new AcceptedInputHandler({ @@ -134,6 +137,7 @@ function scaffold(overrides = {}) { requestDrain: m.requestDrain, reserveDirect: m.reserveDirect, checkpoint: m.checkpoint, + hasMatchingInput: m.hasMatchingInput, admitInput: m.admitInput, discardPreparedInput: m.discardPreparedInput, releaseDirect: m.releaseDirect, @@ -142,6 +146,9 @@ function scaffold(overrides = {}) { deliverGoalControl: m.deliverGoalControl, steer: m.steer, }, + projectAdmission: { + assertAvailable: m.assertProjectAvailable, + }, }); return { m, handler }; } @@ -236,6 +243,88 @@ describe('AcceptedInputHandler', () => { expect(settle.markScheduled).not.toHaveBeenCalled(); }); + test('skips control, preparation, and project admission for a matching input', async () => { + const settle = settlement(); + const { handler, m } = scaffold({ hasMatchingInput: mock(async () => true) }); + const prepare = mock(async () => undefined); + + await handler.schedule({ + command: command(), + content: 'already committed', + options: { clientRequestId: 'request-1', clientMessageId: 'message-1', turnId: 'turn-1' }, + settlement: settle, + preparation: { + operation: 'fork-run', + prepare, + compensate: mock(async () => undefined), + }, + }); + + expect(settle.settleDuplicateInput).toHaveBeenCalledWith(command()); + expect(m.read).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + expect(m.assertProjectAvailable).not.toHaveBeenCalled(); + expect(m.admitInput).not.toHaveBeenCalled(); + }); + + test('compensates preparation when the project is unavailable before transcript admission', async () => { + const events = []; + const unavailable = new ProjectUnavailableError('/workspace/missing', 'not-found'); + const settle = settlement({ + markPreScheduleFailure: mock(async () => { events.push('settled'); }), + }); + const { handler, m } = scaffold({ + assertProjectAvailable: mock(async () => { throw unavailable; }), + releaseDirect: mock(async () => { events.push('released'); }), + }); + + await expect(handler.schedule({ + command: command(), + content: 'new work', + options: { clientRequestId: 'request-1', clientMessageId: 'message-1', turnId: 'turn-1' }, + settlement: settle, + preparation: { + operation: 'chat-start', + prepare: mock(async () => { events.push('prepared'); }), + compensate: mock(async () => { events.push('compensated'); }), + }, + })).rejects.toBe(unavailable); + + expect(events).toEqual(['prepared', 'compensated', 'released', 'settled']); + expect(m.admitInput).not.toHaveBeenCalled(); + expect(m.runDirect).not.toHaveBeenCalled(); + expect(settle.markPreScheduleFailure).toHaveBeenCalledWith(command(), { + error: unavailable, + retryable: true, + preserveForkPreparation: false, + }); + }); + + test('rejects compact when the project is unavailable and releases ownership', async () => { + const unavailable = new ProjectUnavailableError('/workspace/missing', 'not-found'); + const settle = settlement(); + const dispatch = mock(async () => undefined); + const { handler, m } = scaffold({ + assertProjectAvailable: mock(async () => { throw unavailable; }), + }); + + await expect(handler.scheduleOperation({ + command: command(), + settlement: settle, + dispatch, + })).rejects.toBe(unavailable); + + expect(m.releaseDirect).toHaveBeenCalledOnce(); + expect(m.runDirect).not.toHaveBeenCalled(); + expect(m.trackDispatch).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + expect(settle.markScheduled).not.toHaveBeenCalled(); + expect(settle.markPreScheduleFailure).toHaveBeenCalledWith(command(), { + error: unavailable, + retryable: true, + }); + }); + test('admits direct presentation without exposing it to provider run options', async () => { const presentation = { origin: 'cli', style: 'notice', title: 'Context' }; const { handler, m } = scaffold(); diff --git a/server/chat-execution/__tests__/chat-execution-control-operations.test.js b/server/chat-execution/__tests__/chat-execution-control-operations.test.js index f6284e5a1..91592fd85 100644 --- a/server/chat-execution/__tests__/chat-execution-control-operations.test.js +++ b/server/chat-execution/__tests__/chat-execution-control-operations.test.js @@ -1,6 +1,16 @@ import { describe, expect, it, mock } from 'bun:test'; import { ChatExecutionControlOperations } from '../chat-execution-control-operations.ts'; import { InMemoryChatExecutionControlRepository } from '../chat-execution-control-repository.ts'; +import { ProjectUnavailableError } from '../../lib/domain-error.ts'; + +function host() { + return { + runExclusive: (_chatId, operation) => operation(), + chatExists: () => true, + unsettledQueueReceiptKeys: () => new Set(), + publish: () => undefined, + }; +} describe('ChatExecutionControlOperations', () => { it('returns a committed steering reservation when publication fails', async () => { @@ -13,7 +23,7 @@ describe('ChatExecutionControlOperations', () => { publish: () => { if (publicationFails) throw new Error('listener failed'); }, - }); + }, { assertAvailable: mock(async () => undefined) }); const created = await operations.create('chat-1', 'queued guidance'); publicationFails = true; @@ -37,7 +47,7 @@ describe('ChatExecutionControlOperations', () => { chatExists: () => true, unsettledQueueReceiptKeys: () => new Set(), publish, - }); + }, { assertAvailable: mock(async () => undefined) }); const input = { content: '\nmessage\n', transcriptViewId: 'view-1', @@ -63,4 +73,48 @@ describe('ChatExecutionControlOperations', () => { expect(dequeued?.control.version).toBe(0); expect(publish).not.toHaveBeenCalled(); }); + + it('checks project availability before committing a new queue entry', async () => { + const repository = new InMemoryChatExecutionControlRepository('server-instance-test'); + const unavailable = new ProjectUnavailableError('/workspace/missing', 'not-found'); + const operations = new ChatExecutionControlOperations( + repository, + host(), + { assertAvailable: mock(async () => { throw unavailable; }) }, + ); + + await expect(operations.create( + 'chat-1', + 'queued work', + { key: 'command-1', entryId: 'entry-1' }, + )).rejects.toBe(unavailable); + + expect(await repository.load('chat-1')).toMatchObject({ + version: 0, + entries: [], + appliedCommands: [], + }); + }); + + it('does not recheck project availability for a duplicate queue command', async () => { + const repository = new InMemoryChatExecutionControlRepository('server-instance-test'); + const assertAvailable = mock(async () => undefined); + const operations = new ChatExecutionControlOperations( + repository, + host(), + { assertAvailable }, + ); + const command = { key: 'command-1', entryId: 'entry-1' }; + + const created = await operations.create('chat-1', 'queued work', command); + assertAvailable.mockImplementation(async () => { + throw new ProjectUnavailableError('/workspace/missing', 'not-found'); + }); + const duplicate = await operations.create('chat-1', 'queued work', command); + + expect(created.duplicate).toBe(false); + expect(duplicate).toMatchObject({ entryId: created.entryId, duplicate: true }); + expect(assertAvailable).toHaveBeenCalledTimes(1); + expect((await repository.load('chat-1')).entries).toHaveLength(1); + }); }); diff --git a/server/chat-execution/__tests__/chat-execution-control-transitions.test.js b/server/chat-execution/__tests__/chat-execution-control-transitions.test.js index 0cecafd90..659044d55 100644 --- a/server/chat-execution/__tests__/chat-execution-control-transitions.test.js +++ b/server/chat-execution/__tests__/chat-execution-control-transitions.test.js @@ -5,6 +5,7 @@ import { } from '../control-state.ts'; import { clearQueue, + discardPendingInput, consumeQueueSteer, createQueueEntry, dequeueNextTurn, @@ -256,4 +257,34 @@ describe('chat execution control transitions', () => { expect(cleared.next.pause).toBeNull(); expect(value(dequeueNextTurn(cleared.next, context(5)))?.kind).toBe('control'); }); + + it('discards both pending lanes and pauses while preserving receipts', () => { + const created = add(initial(), 'future work', 1, { + command: { key: 'command-1', entryId: 'entry-1' }, + }); + const controlQueued = enqueueControlInput(created.next, controlInput('control'), context(2)); + const paused = pauseQueue(controlQueued.next, context(3)); + const current = { + ...paused.next, + recentlyDispatched: [{ + entryId: 'sent-1', + revision: 1, + dispatchedAt: context(3).now, + }], + resumePauses: [{ id: 'resume-1', kind: 'manual', pausedAt: context(2).now }], + }; + + const discarded = discardPendingInput(current, context(4)); + + expect(discarded.changed).toBe(true); + expect(discarded.next.entries).toEqual([]); + expect(discarded.next.controlEntries).toEqual([]); + expect(discarded.next.pause).toBeNull(); + expect(discarded.next.resumePauses).toBeUndefined(); + expect(discarded.next.appliedCommands).toEqual(current.appliedCommands); + expect(discarded.next.recentlyDispatched).toEqual(current.recentlyDispatched); + expect(discarded.next.version).toBe(current.version + 1); + expect(current.entries).toHaveLength(1); + expect(current.controlEntries).toHaveLength(1); + }); }); diff --git a/server/chat-execution/__tests__/chat-execution-coordinator.test.js b/server/chat-execution/__tests__/chat-execution-coordinator.test.js index 99a636244..9569a3172 100644 --- a/server/chat-execution/__tests__/chat-execution-coordinator.test.js +++ b/server/chat-execution/__tests__/chat-execution-coordinator.test.js @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import { ChatExecutionCoordinator } from '../chat-execution-coordinator.js'; import { InMemoryChatExecutionControlRepository } from '../chat-execution-control-repository.ts'; -import { DomainError } from '../../lib/domain-error.ts'; +import { DomainError, ProjectUnavailableError } from '../../lib/domain-error.ts'; function deferred() { let resolve; @@ -46,6 +46,7 @@ function createFixture(overrides = {}) { const queuedAdmission = overrides.queuedAdmission ?? (() => ({ inserted: true })); const projection = { admitInput: mock(async () => ({ inserted: true })), + hasMatchingInput: mock(async () => false), admitQueuedInput: mock((...args) => { events.push('transcript'); return queuedAdmission(...args); @@ -62,6 +63,9 @@ function createFixture(overrides = {}) { ...overrides.turnRunner, }; const appendControlReceipt = overrides.appendControlReceipt ?? mock(() => undefined); + const projectAdmission = overrides.projectAdmission ?? { + assertAvailable: mock(async () => undefined), + }; const coordinator = new ChatExecutionCoordinator( '/unused', turnRunner, @@ -74,10 +78,13 @@ function createFixture(overrides = {}) { overrides.chatExists ?? (() => true), overrides.controlRepository ?? new InMemoryChatExecutionControlRepository('server-instance-test'), - () => new Set(), - appendControlReceipt, + { + projectAdmission, + unsettledQueueReceiptKeys: () => new Set(), + appendControlReceipt, + }, ); - return { coordinator, events, projection, turnRunner, appendControlReceipt }; + return { coordinator, events, projection, turnRunner, appendControlReceipt, projectAdmission }; } describe('ChatExecutionCoordinator', () => { @@ -343,6 +350,7 @@ describe('ChatExecutionCoordinator', () => { expect((await coordinator.readChatExecutionControl('chat-1')).controlEntries).toEqual([]); expect(fixture.projection.admitInput).not.toHaveBeenCalled(); expect(fixture.projection.admitQueuedInput).not.toHaveBeenCalled(); + expect(fixture.projectAdmission.assertAvailable).not.toHaveBeenCalled(); await coordinator.releaseDirectTurn(reservation); }); @@ -455,6 +463,7 @@ describe('ChatExecutionCoordinator', () => { expect(fixture.turnRunner.steerInput).not.toHaveBeenCalled(); expect(fixture.turnRunner.runAgentTurn).not.toHaveBeenCalled(); + expect(fixture.projectAdmission.assertAvailable).not.toHaveBeenCalled(); expect((await coordinator.readChatExecutionControl('chat-1')).controlEntries) .toHaveLength(1); }); @@ -478,10 +487,12 @@ describe('ChatExecutionCoordinator', () => { const release = coordinator.releaseTranscriptSnapshot(snapshot); await waitFor(() => fixture.turnRunner.runAgentTurn.mock.calls.length === 1); + await release; + expect(coordinator.ownsExecution('chat-1')).toBe(true); const options = fixture.turnRunner.runAgentTurn.mock.calls[0][2]; await coordinator.onAgentTurnTerminal('chat-1', { turnId: options.turnId }); provider.resolve(); - await release; + await coordinator.waitForDispatches(); expect((await coordinator.readChatExecutionControl('chat-1')).controlEntries).toEqual([]); }); @@ -499,10 +510,12 @@ describe('ChatExecutionCoordinator', () => { new AbortController().signal, ); - await expect(coordinator.releaseTranscriptSnapshot(snapshot)).rejects.toBe(failure); + await coordinator.releaseTranscriptSnapshot(snapshot); + await waitFor(() => !coordinator.ownsExecution('chat-1')); expect((await coordinator.readChatExecutionControl('chat-1')).controlEntries) .toHaveLength(1); expect(fixture.turnRunner.runAgentTurn).not.toHaveBeenCalled(); + expect(coordinator.ownsExecution('chat-1')).toBe(false); }); it('continues the control lane after dispatch failure without pausing the user queue', async () => { @@ -585,6 +598,30 @@ describe('ChatExecutionCoordinator', () => { expect(coordinator.ownsExecution('chat-1')).toBe(false); }); + it('rejects a hidden control run when the project is unavailable and releases ownership', async () => { + const unavailable = new ProjectUnavailableError('/workspace/missing', 'not-found'); + const fixture = createFixture({ + projectAdmission: { + assertAvailable: mock(async () => { throw unavailable; }), + }, + }); + coordinator = fixture.coordinator; + const onControlRun = mock(() => undefined); + + await expect(coordinator.deliverControlInput( + 'chat-1', + '1787836573296800', + 'view-1', + null, + new AbortController().signal, + onControlRun, + )).rejects.toBe(unavailable); + + expect(onControlRun).not.toHaveBeenCalled(); + expect(fixture.turnRunner.runAgentTurn).not.toHaveBeenCalled(); + expect(coordinator.ownsExecution('chat-1')).toBe(false); + }); + it('releases hidden control ownership when run options cannot be resolved', async () => { const fixture = createFixture({ getDrainOptions: () => { throw new Error('session disappeared'); }, diff --git a/server/chat-execution/__tests__/queue-drainer.test.js b/server/chat-execution/__tests__/queue-drainer.test.js index 9c9ef5304..3d1e9e2d5 100644 --- a/server/chat-execution/__tests__/queue-drainer.test.js +++ b/server/chat-execution/__tests__/queue-drainer.test.js @@ -1,9 +1,66 @@ import { describe, expect, it, mock } from 'bun:test'; +import { ChatExecutionControlOperations } from '../chat-execution-control-operations.ts'; +import { InMemoryChatExecutionControlRepository } from '../chat-execution-control-repository.ts'; import { QueueDrainer } from '../queue-drainer.ts'; -import { DomainError } from '../../lib/domain-error.ts'; +import { DomainError, ProjectUnavailableError } from '../../lib/domain-error.ts'; const TS = '2026-08-15T00:00:00.000Z'; +function control(entries = [], controlEntries = []) { + return { + serverInstanceId: 'server-1', + entries, + controlEntries, + recentlyDispatched: [], + appliedCommands: [], + pause: null, + reorderRevision: 0, + version: 1, + updatedAt: TS, + }; +} + +function availableProjectAdmission() { + return { assertAvailable: mock(async () => undefined) }; +} + +function privateControlEntry(id = 'control-1') { + return { + id, + content: 'message', + transcriptViewId: 'view-1', + createdAt: TS, + receipt: { + title: 'Inter-agent message', + content: 'message', + detail: { type: 'inter-agent-message-received', fromChatId: null }, + }, + }; +} + +function idleOwnership(overrides = {}) { + return { + hasSuppression: () => false, + hasDirect: () => false, + attempt: () => null, + ...overrides, + }; +} + +function queueCallbacks(overrides = {}) { + return { + isShuttingDown: () => false, + registerQueued: mock(() => true), + appendControlReceipt: mock(() => undefined), + discardPreparedInput: mock(() => undefined), + publishIdle: mock(() => undefined), + publishProjectUnavailable: mock(() => undefined), + publishTurnFailed: mock(() => undefined), + retireAttempt: mock(() => undefined), + ...overrides, + }; +} + describe('QueueDrainer', () => { it('discards a prepared input when queue removal fails after transcript admission', async () => { const failure = new Error('queue removal failed'); @@ -27,12 +84,15 @@ describe('QueueDrainer', () => { attempt: () => null, }, controls: { + read: mock(async () => control([entry])), + pause: mock(async () => ({ control: control([entry]), changed: true })), dequeueNextTurn: mock(async (chatId, admit) => { expect(chatId).toBe('chat-1'); expect(admit({ kind: 'user', entry })).toBe(true); throw failure; }), }, + projectAdmission: availableProjectAdmission(), turnRunner: { isChatRunning: () => false, }, @@ -44,6 +104,7 @@ describe('QueueDrainer', () => { appendControlReceipt: mock(() => undefined), discardPreparedInput, publishIdle: mock(() => undefined), + publishProjectUnavailable: mock(() => undefined), publishTurnFailed: mock(() => undefined), retireAttempt: mock(() => undefined), }, @@ -78,11 +139,14 @@ describe('QueueDrainer', () => { setActiveDrainEntry: mock(() => undefined), }, controls: { + read: mock(async () => control([entry])), + pause: mock(async () => ({ control: control([entry]), changed: true })), dequeueNextTurn: mock(async (_chatId, admit) => { const input = { kind: 'user', entry }; return { input, control: {}, inserted: admit(input) }; }), }, + projectAdmission: availableProjectAdmission(), turnRunner: { isChatRunning: () => false, runAgentTurn, @@ -92,12 +156,13 @@ describe('QueueDrainer', () => { callbacks: { isShuttingDown: () => { shutdownChecks += 1; - return shutdownChecks > 1; + return shutdownChecks > 3; }, registerQueued: mock(() => true), appendControlReceipt: mock(() => undefined), discardPreparedInput: mock(() => undefined), publishIdle: mock(() => undefined), + publishProjectUnavailable: mock(() => undefined), publishTurnFailed: mock(() => undefined), retireAttempt, }, @@ -160,18 +225,10 @@ describe('QueueDrainer', () => { const input = { kind: 'user', entry }; return { input, control: {}, inserted: admit(input) }; }), - read: mock(async () => ({ - serverInstanceId: 'server-1', - entries: [], - controlEntries: [], - recentlyDispatched: [], - appliedCommands: [], - pause: null, - reorderRevision: 0, - version: 1, - updatedAt: TS, - })), + read: mock(async () => control([...entries])), + pause: mock(async () => ({ control: control([...entries]), changed: true })), }, + projectAdmission: availableProjectAdmission(), turnRunner: { isChatRunning: () => false, runAgentTurn, @@ -184,6 +241,7 @@ describe('QueueDrainer', () => { appendControlReceipt: mock(() => undefined), discardPreparedInput: mock(() => undefined), publishIdle: mock(() => undefined), + publishProjectUnavailable: mock(() => undefined), publishTurnFailed, retireAttempt: mock(() => undefined), }, @@ -228,11 +286,14 @@ describe('QueueDrainer', () => { setActiveDrainEntry: mock(() => undefined), }, controls: { + read: mock(async () => control([entry])), + pause: mock(async () => ({ control: control([entry]), changed: true })), dequeueNextTurn: mock(async (_chatId, admit) => { const input = { kind: 'user', entry }; return { input, control: {}, inserted: admit(input) }; }), }, + projectAdmission: availableProjectAdmission(), turnRunner: { isChatRunning: () => attempt?.isSettled ?? false, runAgentTurn: mock(() => providerRun), @@ -245,6 +306,7 @@ describe('QueueDrainer', () => { appendControlReceipt: mock(() => undefined), discardPreparedInput: mock(() => undefined), publishIdle: mock(() => undefined), + publishProjectUnavailable: mock(() => undefined), publishTurnFailed, retireAttempt: mock(() => undefined), }, @@ -283,10 +345,19 @@ describe('QueueDrainer', () => { events.push('dequeue:end'); return null; }), - read: mock(async () => ({ entries: [], controlEntries: [], pause: null })), + read: mock(async () => control([{ + id: 'entry-1', + content: 'queued input', + revision: 1, + createdAt: TS, + updatedAt: TS, + status: 'queued', + submission: { clientMessageId: 'message-1', transcriptViewId: 'view-1' }, + }])), }, turnRunner: { isChatRunning: () => false }, getDrainOptions: () => ({}), + projectAdmission: availableProjectAdmission(), runSelectionAdmissionExclusive: (chatId, operation) => { events.push('lock:enter'); return lock.runExclusive(`chat:${chatId}`, async () => { @@ -304,6 +375,7 @@ describe('QueueDrainer', () => { appendControlReceipt: mock(() => {}), discardPreparedInput: mock(() => {}), publishIdle: mock(() => {}), + publishProjectUnavailable: mock(() => {}), publishTurnFailed: mock(() => {}), retireAttempt: mock(() => {}), }, @@ -312,4 +384,230 @@ describe('QueueDrainer', () => { await drainer.run('chat-1'); expect(events).toEqual(['lock:enter', 'dequeue:begin', 'admit', 'dequeue:end', 'lock:exit']); }); + + it('pauses an unavailable queue before dequeue or transcript admission', async () => { + const entry = { + id: 'entry-1', + content: 'queued input', + revision: 1, + createdAt: TS, + updatedAt: TS, + status: 'queued', + submission: null, + }; + const pending = control([entry], [privateControlEntry()]); + const dequeueNextTurn = mock(async () => null); + const pauseForUnavailableProject = mock(async () => ({ + control: { ...pending, pause: { id: 'pause-1' } }, + changed: true, + })); + const callbacks = queueCallbacks(); + const unavailable = new ProjectUnavailableError('/workspace/missing', 'not-found'); + const drainer = new QueueDrainer({ + ownership: idleOwnership(), + controls: { read: mock(async () => pending), pauseForUnavailableProject, dequeueNextTurn }, + turnRunner: { isChatRunning: () => false, runAgentTurn: mock(async () => undefined) }, + getDrainOptions: () => ({}), + projectAdmission: { assertAvailable: mock(async () => { throw unavailable; }) }, + runSelectionAdmissionExclusive: (_chatId, operation) => operation(), + callbacks, + }); + + await drainer.run('chat-1'); + + expect(pauseForUnavailableProject).toHaveBeenCalledWith('chat-1'); + expect(dequeueNextTurn).not.toHaveBeenCalled(); + expect(callbacks.registerQueued).not.toHaveBeenCalled(); + expect(callbacks.publishProjectUnavailable).toHaveBeenCalledWith('chat-1', unavailable); + expect(callbacks.publishTurnFailed).not.toHaveBeenCalled(); + }); + + it('does not create a hidden pause for an unavailable control-only lane', async () => { + const entry = privateControlEntry(); + const pending = control([], [entry]); + const pauseForUnavailableProject = mock(async () => ({ control: pending, changed: true })); + const assertAvailable = mock(async () => { + throw new ProjectUnavailableError('/workspace/missing', 'not-found'); + }); + const runAgentTurn = mock(async () => { + throw new Error('provider could not start in the project directory'); + }); + const callbacks = queueCallbacks(); + const drainer = new QueueDrainer({ + ownership: idleOwnership({ + installAttempt: () => ({ signal: new AbortController().signal }), + beginFinalization: () => ({ settle: mock(() => undefined) }), + setActiveDrainEntry: mock(() => undefined), + }), + controls: { + read: mock(async () => pending), + pauseForUnavailableProject, + dequeueNextTurn: mock(async (_chatId, admit) => { + const controlEntry = pending.controlEntries.shift(); + if (!controlEntry) return null; + const input = { kind: 'control', entry: controlEntry }; + return { input, control: pending, inserted: admit(input) }; + }), + }, + turnRunner: { isChatRunning: () => false, runAgentTurn }, + getDrainOptions: () => ({}), + projectAdmission: { assertAvailable }, + runSelectionAdmissionExclusive: (_chatId, operation) => operation(), + callbacks, + }); + + await drainer.run('chat-1'); + + expect(assertAvailable).not.toHaveBeenCalled(); + expect(pauseForUnavailableProject).not.toHaveBeenCalled(); + expect(pending.controlEntries).toEqual([]); + expect(callbacks.appendControlReceipt).toHaveBeenCalledWith('chat-1', entry); + expect(runAgentTurn).toHaveBeenCalledTimes(1); + expect(callbacks.publishProjectUnavailable).not.toHaveBeenCalled(); + expect(callbacks.publishTurnFailed).toHaveBeenCalledTimes(1); + }); + + it('rechecks visible pause eligibility after project resolution', async () => { + for (const mutation of ['clear', 'delete']) { + const repository = new InMemoryChatExecutionControlRepository('server-1'); + const controls = new ChatExecutionControlOperations( + repository, + { + runExclusive: (_chatId, operation) => operation(), + chatExists: () => true, + unsettledQueueReceiptKeys: () => new Set(), + publish: () => undefined, + }, + availableProjectAdmission(), + ); + const created = await controls.create('chat-1', 'queued input'); + const { id: _id, ...controlInput } = privateControlEntry(); + await controls.enqueueControl('chat-1', controlInput); + const resolution = Promise.withResolvers(); + const resolutionStarted = Promise.withResolvers(); + const callbacks = queueCallbacks(); + const runAgentTurn = mock(async () => { + throw new Error('provider could not start in the project directory'); + }); + const drainer = new QueueDrainer({ + ownership: idleOwnership({ + installAttempt: () => ({ signal: new AbortController().signal }), + beginFinalization: () => ({ settle: mock(() => undefined) }), + setActiveDrainEntry: mock(() => undefined), + }), + controls, + turnRunner: { isChatRunning: () => false, runAgentTurn }, + getDrainOptions: () => ({}), + projectAdmission: { + assertAvailable: mock(async () => { + resolutionStarted.resolve(); + await resolution.promise; + throw new ProjectUnavailableError('/workspace/missing', 'not-found'); + }), + }, + runSelectionAdmissionExclusive: (_chatId, operation) => operation(), + callbacks, + }); + + const drain = drainer.run('chat-1'); + await resolutionStarted.promise; + if (mutation === 'clear') await controls.clear('chat-1'); + else await controls.delete('chat-1', created.entryId); + resolution.resolve(); + await drain; + + const final = await controls.read('chat-1'); + expect(final.entries, mutation).toEqual([]); + expect(final.controlEntries, mutation).toEqual([]); + expect(final.pause, mutation).toBeNull(); + expect(callbacks.publishProjectUnavailable, mutation).not.toHaveBeenCalled(); + expect(callbacks.appendControlReceipt, mutation).toHaveBeenCalledTimes(1); + expect(runAgentTurn, mutation).toHaveBeenCalledTimes(1); + } + }); + + it('does not resolve empty, paused, or steering-blocked queues', async () => { + const queued = { + id: 'entry-1', + content: 'queued input', + revision: 1, + createdAt: TS, + updatedAt: TS, + status: 'queued', + submission: null, + }; + const assertAvailable = mock(async () => undefined); + const cases = [ + control(), + { ...control([queued]), pause: { id: 'pause-1', kind: 'manual', pausedAt: TS } }, + control([{ ...queued, status: 'steering' }]), + ]; + + for (const pending of cases) { + const drainer = new QueueDrainer({ + ownership: idleOwnership(), + controls: { + read: mock(async () => pending), + pause: mock(async () => ({ control: pending, changed: false })), + pauseForUnavailableProject: mock(async () => ({ control: pending, changed: false })), + dequeueNextTurn: mock(async () => null), + }, + turnRunner: { isChatRunning: () => false, runAgentTurn: mock(async () => undefined) }, + getDrainOptions: () => ({}), + projectAdmission: { assertAvailable }, + runSelectionAdmissionExclusive: (_chatId, operation) => operation(), + callbacks: queueCallbacks(), + }); + await drainer.run('chat-1'); + } + + expect(assertAvailable).not.toHaveBeenCalled(); + }); + + it('halts after deferred resolution when Stop acquires suppression', async () => { + const entry = { + id: 'entry-1', + content: 'queued input', + revision: 1, + createdAt: TS, + updatedAt: TS, + status: 'queued', + submission: null, + }; + let suppressed = false; + let finishResolution; + const resolution = new Promise((resolve) => { finishResolution = resolve; }); + const resolutionStarted = Promise.withResolvers(); + const dequeueNextTurn = mock(async () => null); + const drainer = new QueueDrainer({ + ownership: idleOwnership({ hasSuppression: () => suppressed }), + controls: { + read: mock(async () => control([entry])), + pause: mock(async () => ({ control: control([entry]), changed: true })), + pauseForUnavailableProject: mock(async () => ({ + control: control([entry]), + changed: true, + })), + dequeueNextTurn, + }, + turnRunner: { isChatRunning: () => false, runAgentTurn: mock(async () => undefined) }, + getDrainOptions: () => ({}), + projectAdmission: { + assertAvailable: mock(async () => { + resolutionStarted.resolve(); + await resolution; + }), + }, + runSelectionAdmissionExclusive: (_chatId, operation) => operation(), + callbacks: queueCallbacks(), + }); + + const drain = drainer.run('chat-1'); + await resolutionStarted.promise; + suppressed = true; + finishResolution(); + await drain; + + expect(dequeueNextTurn).not.toHaveBeenCalled(); + }); }); diff --git a/server/chat-execution/accepted-input-handler.ts b/server/chat-execution/accepted-input-handler.ts index 720f031ae..c5539827c 100644 --- a/server/chat-execution/accepted-input-handler.ts +++ b/server/chat-execution/accepted-input-handler.ts @@ -32,9 +32,11 @@ import type { AcceptedQueueEntrySteer, AcceptedQueueEntrySteerOutcome, CapturedSteerTarget, + DirectInputScheduleOutcome, DirectTurnReservation, UserInputAdmissionOptions, QueueCommandMutationResult, + ProjectAdmissionPort, } from './types.ts'; import { hasPendingTurnInput, @@ -50,6 +52,11 @@ export interface AcceptedInputCoordinator { requestDrain(chatId: string, context: string): void; reserveDirect(chatId: string, turn: TurnIdentity): DirectTurnReservation; checkpoint(reservation: DirectTurnReservation): void; + hasMatchingInput( + chatId: string, + content: string, + options: UserInputAdmissionOptions, + ): boolean; admitInput( chatId: string, content: string, @@ -85,15 +92,18 @@ export interface AcceptedInputCoordinator { export interface AcceptedInputDeps { controls: ChatExecutionControlOperations; coordinator: AcceptedInputCoordinator; + projectAdmission: ProjectAdmissionPort; } export class AcceptedInputHandler { readonly #controls: ChatExecutionControlOperations; readonly #coordinator: AcceptedInputCoordinator; + readonly #projectAdmission: ProjectAdmissionPort; constructor(deps: AcceptedInputDeps) { this.#controls = deps.controls; this.#coordinator = deps.coordinator; + this.#projectAdmission = deps.projectAdmission; } async enqueue(input: AcceptedQueueCreate): Promise { @@ -173,14 +183,15 @@ export class AcceptedInputHandler { } } - async schedule(input: AcceptedDirectInput): Promise { + async schedule(input: AcceptedDirectInput): Promise { const reservation = await this.#prepareDirect(input); - if (!reservation) return; + if (!reservation) return 'duplicate'; this.#coordinator.trackDispatch( this.#coordinator.runDirect(reservation, input.content, input.options, input.dispatch).catch((error) => { logger.error('commands: run failed:', error instanceof Error ? error.message : String(error)); }), ); + return 'scheduled'; } async runInitial(input: AcceptedDirectInput): Promise { @@ -208,6 +219,10 @@ export class AcceptedInputHandler { this.#checkpoint(reservation); const control = await this.#checkpointAfter(reservation, this.#controls.read(input.command.chatId)); assertDirectControlAvailable(control); + await this.#checkpointAfter( + reservation, + this.#projectAdmission.assertAvailable(input.command.chatId), + ); await this.#checkpointAfter( reservation, input.settlement.markScheduled(input.command, options.turnId!), @@ -561,12 +576,29 @@ export class AcceptedInputHandler { } try { this.#checkpoint(reservation); + const duplicate = await this.#checkpointAfter( + reservation, + Promise.resolve(this.#coordinator.hasMatchingInput( + input.command.chatId, + input.content, + { ...input.options, userMessagePresentation: input.userMessagePresentation }, + )), + ); + if (duplicate) { + await input.settlement.settleDuplicateInput(input.command); + await this.#coordinator.releaseDirect(reservation); + return null; + } const control = await this.#checkpointAfter(reservation, this.#controls.read(input.command.chatId)); assertDirectControlAvailable(control); await this.#checkpointAfter(reservation, Promise.resolve(input.preparation?.prepare({ signal: reservation.executionAdmission.signal, assertAdmissionActive: () => this.#checkpoint(reservation), }))); + await this.#checkpointAfter( + reservation, + this.#projectAdmission.assertAvailable(input.command.chatId), + ); const inserted = await this.#checkpointAfter( reservation, this.#coordinator.admitInput(input.command.chatId, input.content, { ...input.options, userMessagePresentation: input.userMessagePresentation }), diff --git a/server/chat-execution/accepted-input-transcript.ts b/server/chat-execution/accepted-input-transcript.ts index a89b0f3de..a94417fb4 100644 --- a/server/chat-execution/accepted-input-transcript.ts +++ b/server/chat-execution/accepted-input-transcript.ts @@ -12,6 +12,11 @@ export interface AcceptedInputTranscriptResult { } export interface AcceptedInputTranscriptPort { + hasMatchingInput( + chatId: string, + message: UserMessage, + options: UserInputAdmissionOptions, + ): boolean; admitInput( chatId: string, message: UserMessage, @@ -28,6 +33,19 @@ export interface AcceptedInputTranscriptPort { export class AcceptedInputTranscript { constructor(private readonly transcript: AcceptedInputTranscriptPort) {} + hasMatching( + chatId: string, + content: string, + options: UserInputAdmissionOptions, + ): boolean { + if (!content && !options.images?.length) return false; + return this.transcript.hasMatchingInput( + chatId, + inputMessage(content, options), + options, + ); + } + async register( chatId: string, content: string, @@ -37,10 +55,9 @@ export class AcceptedInputTranscript { if (!options.clientRequestId) { throw new TypeError('Accepted input is missing a client request ID'); } - const images = normalizeChatImages(options.images); const result = await this.transcript.admitInput( chatId, - new UserMessage(options.createdAt ?? new Date().toISOString(), content, images, undefined, options.userMessagePresentation), + inputMessage(content, options), { ...options, clientRequestId: options.clientRequestId }, ); return result.inserted !== false; @@ -69,6 +86,16 @@ export class AcceptedInputTranscript { } } +function inputMessage(content: string, options: UserInputAdmissionOptions): UserMessage { + return new UserMessage( + options.createdAt ?? new Date().toISOString(), + content, + normalizeChatImages(options.images), + undefined, + options.userMessagePresentation, + ); +} + function normalizeChatImages(images: RunAgentTurnOptions['images']): ChatImage[] | undefined { if (!images?.length) return undefined; return images.map((image, index) => ({ diff --git a/server/chat-execution/chat-execution-control-operations.ts b/server/chat-execution/chat-execution-control-operations.ts index 0b53616d5..6e4c6cf80 100644 --- a/server/chat-execution/chat-execution-control-operations.ts +++ b/server/chat-execution/chat-execution-control-operations.ts @@ -12,6 +12,7 @@ import { createLogger } from '../lib/log.ts'; import type { ChatExecutionControlRepository } from './chat-execution-control-repository.ts'; import { clearQueue, + discardPendingInput, createQueueEntry, deleteQueueEntry, moveQueueEntry, @@ -32,6 +33,7 @@ import { import { transitionContext, transitionError, + type ProjectAdmissionPort, type QueueCommandMutationResult, } from './types.ts'; @@ -44,10 +46,13 @@ export interface ChatExecutionControlOperationsHost { publish(chatId: string, control: StoredChatExecutionControlState): void; } +type ControlChangeResult = { control: StoredChatExecutionControlState; changed: boolean }; + export class ChatExecutionControlOperations { constructor( private readonly repository: ChatExecutionControlRepository, private readonly host: ChatExecutionControlOperationsHost, + private readonly projectAdmission: ProjectAdmissionPort, ) {} async read(chatId: string): Promise { @@ -64,11 +69,15 @@ export class ChatExecutionControlOperations { ): Promise { return this.host.runExclusive(chatId, async () => { const current = await this.#load(chatId); - const committed = await this.#commitTransition( - chatId, + const transition = createQueueEntry( current, - createQueueEntry(current, { content, command, submission }, this.#transitionContext(chatId)), + { content, command, submission }, + this.#transitionContext(chatId), ); + if (transition.outcome.status === 'ok' && !transition.outcome.value.duplicate) { + await this.projectAdmission.assertAvailable(chatId); + } + const committed = await this.#commitTransition(chatId, current, transition); const result = committed.value; if (!result.duplicate) { this.#logMutation('create', chatId, result.entryId, committed.control, result.entry?.revision); @@ -195,6 +204,17 @@ export class ChatExecutionControlOperations { }); } + async discardPendingInput(chatId: string): Promise { + return this.host.runExclusive(chatId, async () => { + const current = await this.#load(chatId); + return (await this.#commitTransition( + chatId, + current, + discardPendingInput(current, transitionContext()), + )).control; + }); + } + async enqueueControl( chatId: string, input: Omit, @@ -212,23 +232,36 @@ export class ChatExecutionControlOperations { }); } - async pause(chatId: string): Promise { + async pause(chatId: string): Promise { + return this.#pause(chatId); + } + + async pauseForUnavailableProject(chatId: string): Promise { + return this.#pause(chatId, (current) => + current.entries.length > 0 && current.entries.every((entry) => entry.status === 'queued')); + } + + async #pause( + chatId: string, + eligible: (control: StoredChatExecutionControlState) => boolean = () => true, + ): Promise { return this.host.runExclusive(chatId, async () => { const current = await this.#load(chatId); + if (!eligible(current)) return { control: cloneStoredChatExecutionControl(current), changed: false }; const committed = await this.#commitTransition( chatId, current, pauseQueue(current, transitionContext()), ); if (committed.changed) this.#logPauseMutation('pause', chatId, committed.control); - return committed.control; + return { control: committed.control, changed: committed.changed }; }); } async resume( chatId: string, pauseId: string, - ): Promise<{ control: StoredChatExecutionControlState; changed: boolean }> { + ): Promise { return this.host.runExclusive(chatId, async () => { const current = await this.#load(chatId); const committed = await this.#commitTransition( diff --git a/server/chat-execution/chat-execution-control-transitions.ts b/server/chat-execution/chat-execution-control-transitions.ts index cdaada3c7..b3eeb22c9 100644 --- a/server/chat-execution/chat-execution-control-transitions.ts +++ b/server/chat-execution/chat-execution-control-transitions.ts @@ -433,6 +433,27 @@ export function clearQueue( return accepted(next, undefined, true); } +export function discardPendingInput( + current: StoredChatExecutionControlState, + context: TransitionContext, +): ControlTransition { + const next = cloneStoredChatExecutionControl(current); + if ( + next.entries.length === 0 + && next.controlEntries.length === 0 + && !next.pause + && !next.resumePauses?.length + ) { + return accepted(next, undefined, false); + } + next.entries = []; + next.controlEntries = []; + next.pause = null; + delete next.resumePauses; + bump(next, context.now); + return accepted(next, undefined, true); +} + export function pauseQueue( current: StoredChatExecutionControlState, context: TransitionContext, diff --git a/server/chat-execution/chat-execution-coordinator.ts b/server/chat-execution/chat-execution-coordinator.ts index f7807efac..ff17e50e4 100644 --- a/server/chat-execution/chat-execution-coordinator.ts +++ b/server/chat-execution/chat-execution-coordinator.ts @@ -55,6 +55,8 @@ import { type ServerControlInput, type UserInputAdmissionOptions, type ProcessingInvalidatedCallback, + type ProjectAdmissionPort, + type ProjectUnavailableCallback, type QueueCommandMutationResult, type QueueDrainOptionsResolver, type SessionStoppedCallback, @@ -89,6 +91,13 @@ export { const logger = createLogger('queue'); +interface ChatExecutionCoordinatorOptions { + projectAdmission: ProjectAdmissionPort; + unsettledQueueReceiptKeys?: (chatId: string) => ReadonlySet; + appendControlReceipt?: (chatId: string, entry: StoredControlInputEntry) => void; + selectionAdmissionLock?: KeyedPromiseLock; +} + export class ChatExecutionCoordinator extends EventEmitter implements ChatExecutionService { #locks = new KeyedPromiseLock(); #shuttingDown = false; @@ -98,6 +107,7 @@ export class ChatExecutionCoordinator extends EventEmitter ReadonlySet = () => new Set(), - appendControlReceipt: (chatId: string, entry: StoredControlInputEntry) => void = () => undefined, - selectionAdmissionLock: KeyedPromiseLock = new KeyedPromiseLock(), + options: ChatExecutionCoordinatorOptions, ) { super(); if (!turnRunner) throw new Error('ChatExecutionCoordinator requires an agent turn runner'); @@ -126,9 +134,16 @@ export class ChatExecutionCoordinator extends EventEmitter new Set()); + const appendControlReceipt = options.appendControlReceipt ?? (() => undefined); + const selectionAdmissionLock = options.selectionAdmissionLock ?? new KeyedPromiseLock(); this.#turnRunner = turnRunner; this.#getDrainOptions = getDrainOptions; this.#chatExists = chatExists; + this.#projectAdmission = options.projectAdmission; this.#acceptedInputTranscript = new AcceptedInputTranscript(inputTranscript); this.#controlOperations = new ChatExecutionControlOperations(controls, { runExclusive: (chatId, operation) => this.#locks.runExclusive(`chat:${chatId}`, operation), @@ -137,7 +152,7 @@ export class ChatExecutionCoordinator extends EventEmitter { this.emit('execution-control-updated', chatId, control); }, - }); + }, options.projectAdmission); const inputDeliveryOptions = { turnRunner: this.#turnRunner, ownership: this.#ownership, @@ -182,6 +197,9 @@ export class ChatExecutionCoordinator extends EventEmitter ( + this.#acceptedInputTranscript.hasMatching(chatId, content, inputOptions) + ), admitInput: (chatId, content, options) => ( this.admitUserInput(chatId, content, options) ), @@ -198,6 +216,7 @@ export class ChatExecutionCoordinator extends EventEmitter this.#steerInputDelivery.deliver(...args), }, + projectAdmission: options.projectAdmission, }); this.#queueDrainer = new QueueDrainer({ ownership: this.#ownership, @@ -206,6 +225,7 @@ export class ChatExecutionCoordinator extends EventEmitter selectionAdmissionLock.runExclusive(`chat:${chatId}`, operation), + projectAdmission: options.projectAdmission, callbacks: { isShuttingDown: () => this.#shuttingDown, registerQueued: (chatId, content, options) => ( @@ -216,6 +236,9 @@ export class ChatExecutionCoordinator extends EventEmitter { this.emit('chat-idle', chatId); }, + publishProjectUnavailable: (chatId, error) => { + this.emit('project-unavailable', chatId, error); + }, publishTurnFailed: (chatId, message, options) => { this.emit('turn-failed', chatId, message, options); }, @@ -236,6 +259,9 @@ export class ChatExecutionCoordinator extends EventEmitter { - await this.#acceptedInputHandler.schedule(input); + async scheduleDirectInput(input: AcceptedDirectInput): Promise { + return this.#acceptedInputHandler.schedule(input); } async runInitialInput(input: AcceptedDirectInput): Promise { @@ -493,8 +519,12 @@ export class ChatExecutionCoordinator extends EventEmitter { + return this.#controlOperations.discardPendingInput(chatId); + } + async pauseChatQueue(chatId: string): Promise { - return this.#controlOperations.pause(chatId); + return (await this.#controlOperations.pause(chatId)).control; } async resumeChatQueue(chatId: string, pauseId: string): Promise { @@ -552,7 +582,12 @@ export class ChatExecutionCoordinator extends EventEmitter { + logger.warn('Deferred queue drain failed after exclusive operation release', { + chatId: reservation.chatId, + error: error instanceof Error ? error.message : String(error), + }); + }); } completeDirectTurn(reservation: DirectTurnReservation): Promise { return this.#finishDirect(reservation, 'completed'); } @@ -760,6 +795,9 @@ export class ChatExecutionCoordinator extends EventEmitter(chatId: string, operation: () => Promise): Promise; + projectAdmission: ProjectAdmissionPort; callbacks: QueueDispatchCallbacks; } @@ -87,6 +90,30 @@ export class QueueDrainer { continue; } + const pending = await controls.read(chatId); + if (!hasPendingTurnInput(pending)) { + callbacks.publishIdle(chatId); + return; + } + if ( + pending.pause + || pending.entries.some((entry) => entry.status === 'steering') + || this.#shouldHalt(chatId) + ) return; + if (pending.entries.length > 0) { + try { + await this.deps.projectAdmission.assertAvailable(chatId); + } catch (error) { + if (!(error instanceof ProjectUnavailableError)) throw error; + if (this.#shouldHalt(chatId)) return; + const paused = await controls.pauseForUnavailableProject(chatId); + if (!paused.changed) continue; + callbacks.publishProjectUnavailable(chatId, error); + return; + } + } + if (this.#shouldHalt(chatId)) return; + let options: RunAgentTurnOptions | undefined; let inputInserted = false; const admission = { failure: null as DomainError | null }; diff --git a/server/chat-execution/types.ts b/server/chat-execution/types.ts index ef633e291..a3648bfb2 100644 --- a/server/chat-execution/types.ts +++ b/server/chat-execution/types.ts @@ -149,6 +149,8 @@ export interface AcceptedDirectInput { dispatch?: (admission: AgentExecutionAdmission) => Promise; userMessagePresentation?: UserMessagePresentation; } +export type DirectInputScheduleOutcome = 'scheduled' | 'duplicate'; + export interface AcceptedDirectOperation { command: AcceptedExecutionCommand; settlement: CommandSettlementPort; @@ -266,6 +268,10 @@ export interface AgentTurnRunnerPort { isChatRunning(chatId: string): boolean; } +export interface ProjectAdmissionPort { + assertAvailable(chatId: string): Promise; +} + export type ExecutionControlUpdatedCallback = ( chatId: string, control: StoredChatExecutionControlState, @@ -276,6 +282,10 @@ export type SessionStoppedCallback = ( intent: ChatStopIntent, ) => void; export type ChatIdleCallback = (chatId: string) => void; +export type ProjectUnavailableCallback = ( + chatId: string, + error: import('../lib/domain-error.ts').ProjectUnavailableError, +) => void; export type ProcessingInvalidatedCallback = (chatId: string) => void; export type TurnFailedCallback = ( chatId: string, @@ -290,6 +300,7 @@ export interface ChatExecutionCoordinatorEvents { 'execution-control-updated': Parameters; 'session-stopped': Parameters; 'chat-idle': Parameters; + 'project-unavailable': Parameters; 'turn-failed': Parameters; 'turn-settled': Parameters; 'processing-invalidated': Parameters; @@ -300,7 +311,7 @@ export type DrainSuppressionReason = 'abort' | 'manual-stop' | 'deletion'; // Accepted-command surface consumed by the command service and route handlers. export interface ChatExecutionCommands { deleteChatQueueFile(chatId: string): Promise; - scheduleDirectInput(input: AcceptedDirectInput): Promise; + scheduleDirectInput(input: AcceptedDirectInput): Promise; runInitialInput(input: AcceptedDirectInput): Promise; scheduleDirectOperation(input: AcceptedDirectOperation): Promise; enqueueAccepted(input: AcceptedQueueCreate): Promise; @@ -328,6 +339,7 @@ export interface ChatExecutionCommands { ownsExecution(chatId: string): boolean; readChatExecutionControl(chatId: string): Promise; clearChatQueue(chatId: string): Promise; + discardPendingChatInput(chatId: string): Promise; pauseChatQueue(chatId: string): Promise; resumeChatQueue(chatId: string, pauseId: string): Promise; resumeAndDrain(chatId: string, pauseId: string): Promise; diff --git a/server/chats/__tests__/chat-list-projector.test.js b/server/chats/__tests__/chat-list-projector.test.js index bc2045d01..e951ab000 100644 --- a/server/chats/__tests__/chat-list-projector.test.js +++ b/server/chats/__tests__/chat-list-projector.test.js @@ -60,14 +60,6 @@ function makeDeps() { }, processing: { phase: mock(() => 'running') }, canReloadFromNativeHistory: mock(() => true), - pathCache: { - resolveProjectPath: mock(() => - Promise.resolve({ - available: true, - effectiveProjectKey: '/real/project', - }), - ), - }, }, }; } @@ -76,24 +68,13 @@ describe('ChatListProjector', () => { it('projects the complete canonical list entry for list and command paths', async () => { const { deps, session } = makeDeps(); const projector = new ChatListProjector(deps); - const statuses = new Map([ - [ - '/alias', - { - available: true, - effectiveProjectKey: '/real/project', - }, - ], - ]); - - const many = await projector.buildMany([[CHAT_ID, session]], statuses); - const one = await projector.buildOne(CHAT_ID); + const many = projector.buildMany([[CHAT_ID, session]]); + const one = projector.buildOne(CHAT_ID); expect(one).toEqual(many.get(CHAT_ID)); expect(one).toMatchObject({ id: CHAT_ID, parentChat: null, - effectiveProjectKey: '/real/project', orderGroup: 'normal', isPinned: false, isArchived: false, @@ -105,7 +86,7 @@ describe('ChatListProjector', () => { }); }); - it('projects immutable child parentage without changing the summary', async () => { + it('projects immutable child parentage without changing the summary', () => { const parentChat = Object.freeze({ chatId: '1783725900000800', relation: 'fork', @@ -116,12 +97,9 @@ describe('ChatListProjector', () => { session.parentChat = parentChat; const projector = new ChatListProjector(deps); - const many = await projector.buildMany( - [[CHAT_ID, session]], - new Map([['/alias', { available: true, effectiveProjectKey: '/real/project' }]]), - ); + const many = projector.buildMany([[CHAT_ID, session]]); - expect((await projector.buildOne(CHAT_ID))?.parentChat).toBe(parentChat); + expect(projector.buildOne(CHAT_ID)?.parentChat).toBe(parentChat); expect(many.get(CHAT_ID)?.parentChat).toBe(parentChat); expect(projector.buildSummary(CHAT_ID)?.chat).not.toHaveProperty('parentChat'); }); @@ -130,9 +108,6 @@ describe('ChatListProjector', () => { const { deps, session } = makeDeps(); session.tags = ['Review Needed', 'cli', 'review-needed']; session.modelProtocol = 'anthropic-messages'; - deps.pathCache.resolveProjectPath.mockImplementation(() => { - throw new Error('summary must not resolve the project path'); - }); const projector = new ChatListProjector(deps); expect(projector.buildSummary(CHAT_ID)).toEqual({ @@ -158,7 +133,6 @@ describe('ChatListProjector', () => { }, processingPhase: 'running', }); - expect(deps.pathCache.resolveProjectPath).not.toHaveBeenCalled(); expect(deps.processing.phase).toHaveBeenCalledTimes(1); }); @@ -184,36 +158,25 @@ describe('ChatListProjector', () => { ); }); - it('uses pinned, normal, archived precedence for corrupt overlap', async () => { + it('uses pinned, normal, archived precedence for corrupt overlap', () => { const { deps } = makeDeps(); deps.settings.getPinnedChatIds.mockReturnValue([CHAT_ID]); deps.settings.getArchivedChatIds.mockReturnValue([CHAT_ID]); const projector = new ChatListProjector(deps); - const entry = await projector.buildOne(CHAT_ID); + const entry = projector.buildOne(CHAT_ID); expect(entry?.orderGroup).toBe('pinned'); expect(entry?.isPinned).toBe(true); expect(entry?.isArchived).toBe(false); }); - it('omits unavailable sessions', async () => { + it('includes sessions without resolving their project paths', () => { const { deps, session } = makeDeps(); const projector = new ChatListProjector(deps); - const entries = await projector.buildMany( - [[CHAT_ID, session]], - new Map([ - [ - '/alias', - { - available: false, - effectiveProjectKey: null, - }, - ], - ]), - ); + const entries = projector.buildMany([[CHAT_ID, session]]); - expect(entries.size).toBe(0); + expect(entries.get(CHAT_ID)?.projectPath).toBe('/alias'); }); }); diff --git a/server/chats/__tests__/inter-agent-message-controller.test.js b/server/chats/__tests__/inter-agent-message-controller.test.js index 0969c680d..9a30917ef 100644 --- a/server/chats/__tests__/inter-agent-message-controller.test.js +++ b/server/chats/__tests__/inter-agent-message-controller.test.js @@ -1,5 +1,5 @@ import { describe, expect, it, mock } from 'bun:test'; -import { DomainError } from '../../lib/domain-error.ts'; +import { DomainError, ProjectUnavailableError } from '../../lib/domain-error.ts'; import { KeyedPromiseLock } from '../../lib/keyed-lock.ts'; import { InterAgentMessageController } from '../inter-agent-message-controller.ts'; @@ -265,6 +265,23 @@ describe('InterAgentMessageController', () => { }); }); + it('classifies project admission failure as target unavailable', async () => { + const fixture = createFixture({ + execution: { + deliverInterAgentControlInput: mock(async () => { + throw new ProjectUnavailableError('/workspace/project', 'not-found'); + }), + }, + }); + + fixture.controller.request(request()); + await waitFor(() => sourceNotices(fixture).length === 1); + + expect(sourceNotices(fixture)[0][2].detail.results).toEqual([ + { chatId: TARGET_CHAT_ID, status: 'failed', reason: 'target-unavailable' }, + ]); + }); + it('keeps an accepted delivery successful when its target receipt cannot be stored', async () => { const receiptError = new Error('receipt failed'); const fixture = createFixture({ diff --git a/server/chats/__tests__/path-cache.test.js b/server/chats/__tests__/path-cache.test.js deleted file mode 100644 index d2021ef8f..000000000 --- a/server/chats/__tests__/path-cache.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { promises as fs } from 'fs'; -import os from 'os'; -import path from 'path'; -import { randomUUID } from 'crypto'; -import { PathCache } from '../path-cache.ts'; - -describe('PathCache', () => { - let basePath; - let originalBasePath; - - beforeEach(async () => { - originalBasePath = process.env.GARCON_PROJECT_BASE_DIR; - basePath = path.join(os.tmpdir(), `garcon-path-cache-${randomUUID()}`); - await fs.mkdir(basePath, { recursive: true }); - process.env.GARCON_PROJECT_BASE_DIR = basePath; - }); - - afterEach(async () => { - if (originalBasePath === undefined) - delete process.env.GARCON_PROJECT_BASE_DIR; - else process.env.GARCON_PROJECT_BASE_DIR = originalBasePath; - await fs.rm(basePath, { recursive: true, force: true }); - }); - - it('resolves canonical directories and aliases to one effective key', async () => { - const realPath = path.join(basePath, 'real'); - const aliasPath = path.join(basePath, 'alias'); - await fs.mkdir(realPath); - await fs.symlink(realPath, aliasPath); - const cache = new PathCache(); - - const real = await cache.resolveProjectPath(realPath); - const alias = await cache.resolveProjectPath(aliasPath); - - expect(real).toEqual({ - available: true, - effectiveProjectKey: await fs.realpath(realPath), - }); - expect(alias).toEqual(real); - }); - - it('normalizes missing and outside-base paths to unavailable', async () => { - const cache = new PathCache(); - - expect( - await cache.resolveProjectPath(path.join(basePath, 'missing')), - ).toEqual({ - available: false, - effectiveProjectKey: null, - }); - expect(await cache.resolveProjectPath(os.tmpdir())).toEqual({ - available: false, - effectiveProjectKey: null, - }); - }); - - it('deduplicates batch paths and preserves first-seen result order', async () => { - const one = path.join(basePath, 'one'); - const two = path.join(basePath, 'two'); - await fs.mkdir(one); - await fs.mkdir(two); - const cache = new PathCache(); - - const result = await cache.resolveProjectPaths([two, one, two], 2); - - expect([...result.keys()]).toEqual([two, one]); - expect(result.get(one)?.effectiveProjectKey).toBe(await fs.realpath(one)); - }); -}); diff --git a/server/chats/__tests__/store.test.js b/server/chats/__tests__/store.test.js index 75e924095..9233fd076 100644 --- a/server/chats/__tests__/store.test.js +++ b/server/chats/__tests__/store.test.js @@ -752,7 +752,6 @@ describe('ChatRegistry', () => { projectPath: '/next', effectiveProjectKey: '/real/next', previousProjectPath: '/repo', - previousEffectiveProjectKey: '/real/repo', nativeSession: callerSession, }, { flush: true }); @@ -770,7 +769,6 @@ describe('ChatRegistry', () => { projectPath: '/next', effectiveProjectKey: '/real/next', previousProjectPath: '/repo', - previousEffectiveProjectKey: '/real/repo', }); }); @@ -789,6 +787,22 @@ describe('ChatRegistry', () => { }); }); + it('keeps a persisted project-path update when a listener throws', async () => { + registry.addChat(newChat({ nativeSession: nativeSession('test') })); + registry.onChatProjectPathUpdated(() => { + throw new Error('listener failed'); + }); + + await expect(registry.updateProjectPath(CHAT_ID, { + chatId: CHAT_ID, + projectPath: '/next', + effectiveProjectKey: '/real/next', + previousProjectPath: '/repo', + }, { flush: true })).resolves.toMatchObject({ projectPath: '/next' }); + + expect(registry.getChat(CHAT_ID)?.projectPath).toBe('/next'); + }); + it('restores project-path fields in memory when persistence fails', async () => { const originalNativeSession = nativeSession('test'); registry.addChat(newChat({ nativeSession: originalNativeSession })); @@ -800,7 +814,6 @@ describe('ChatRegistry', () => { projectPath: '/next', effectiveProjectKey: '/next', previousProjectPath: '/repo', - previousEffectiveProjectKey: '/repo', nativeSession: nativeSession('test', { path: '/tmp/next.jsonl' }), }, { flush: true })).rejects.toThrow('disk full'); diff --git a/server/chats/chat-list-projector.ts b/server/chats/chat-list-projector.ts index 92f15bc65..9e08e852b 100644 --- a/server/chats/chat-list-projector.ts +++ b/server/chats/chat-list-projector.ts @@ -9,7 +9,6 @@ import { chatIdCreatedAt } from '../../common/chat-id.js'; import { normalizeTags } from '../../common/tags.js'; import type { ChatMetadata } from './metadata-store.js'; import type { ChatRegistryEntry, IChatRegistry } from './store.js'; -import type { PathCache, ProjectPathStatus } from './path-cache.js'; import { extractFirstLine } from '../lib/text.js'; import { carryOverRevision } from './carryover-segments.js'; @@ -36,7 +35,6 @@ export interface ChatListProjectorDeps { settings: ChatListProjectorSettings; metadata: ChatListProjectorMetadata; processing: { phase(chatId: string): ChatProcessingPhase | null }; - pathCache: Pick; canReloadFromNativeHistory(chatId: string, session: ChatRegistryEntry): boolean; } @@ -66,16 +64,13 @@ export class ChatListProjector { ); } - async buildMany( + buildMany( sessions: readonly (readonly [string, ChatRegistryEntry])[], - statuses: ReadonlyMap, - ): Promise> { + ): Map { const metadata = this.deps.metadata.listAllChatMetadata(); const membership = this.membershipSnapshot(); const entries = new Map(); for (const [chatId, session] of sessions) { - const status = statuses.get(session.projectPath); - if (!status?.available || !status.effectiveProjectKey) continue; const chatMetadata = metadata.get(chatId) ?? null; const summary = this.#summary(chatId, session, chatMetadata); entries.set( @@ -83,7 +78,6 @@ export class ChatListProjector { this.#listEntry( summary, session, - status.effectiveProjectKey, chatMetadata, membership, ), @@ -92,19 +86,14 @@ export class ChatListProjector { return entries; } - async buildOne(chatId: string): Promise { + buildOne(chatId: string): ChatListEntry | null { const session = this.deps.registry.getChat(chatId); if (!session) return null; - const status = await this.deps.pathCache.resolveProjectPath( - session.projectPath, - ); - if (!status.available || !status.effectiveProjectKey) return null; const metadata = this.deps.metadata.getChatMetadata(chatId); const summary = this.#summary(chatId, session, metadata); return this.#listEntry( summary, session, - status.effectiveProjectKey, metadata, this.membershipSnapshot(), ); @@ -151,7 +140,6 @@ export class ChatListProjector { #listEntry( summary: ChatSummaryProjection, session: ChatRegistryEntry, - effectiveProjectKey: string, metadata: ChatMetadata | null, membership: ChatListMembershipSnapshot, ): ChatListEntry { @@ -182,7 +170,6 @@ export class ChatListProjector { }, title, projectPath: chat.projectPath, - effectiveProjectKey, orderGroup, tags: chat.tags, activity: { diff --git a/server/chats/inter-agent-message-controller.ts b/server/chats/inter-agent-message-controller.ts index 650f81327..0b9cbb1e7 100644 --- a/server/chats/inter-agent-message-controller.ts +++ b/server/chats/inter-agent-message-controller.ts @@ -326,6 +326,7 @@ function classifyDeliveryFailure(error: unknown): InterAgentMessageFailureReason case 'SERVER_SHUTTING_DOWN': return 'server-shutting-down'; case 'SESSION_BUSY': + case 'PROJECT_UNAVAILABLE': case 'STALE_TRANSCRIPT_VIEW': case 'TRANSCRIPT_UNAVAILABLE': case 'TRANSCRIPT_DEFERRED': diff --git a/server/chats/path-cache.ts b/server/chats/path-cache.ts deleted file mode 100644 index e299c84f0..000000000 --- a/server/chats/path-cache.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Caches canonical project identity so chat-list reads avoid repeated filesystem work. - -import { promises as fs } from 'fs'; -import { mapWithConcurrencyResult } from '../lib/concurrency.js'; -import { - assertRealWithinProjectBase, - isProjectBoundaryError, -} from '../lib/path-boundary.js'; - -const DEFAULT_STALE_MS = 15 * 60 * 1000; -const DEFAULT_MAX_SIZE = 1024; - -interface PathCacheOptions { - ttlMs?: number; - maxSize?: number; -} - -interface PathCacheEntry { - status: ProjectPathStatus; - checkedAt: number; -} - -export interface ProjectPathStatus { - available: boolean; - effectiveProjectKey: string | null; -} - -export class PathCache { - #cache = new Map(); - #ttlMs: number; - #maxSize: number; - - constructor({ - ttlMs = DEFAULT_STALE_MS, - maxSize = DEFAULT_MAX_SIZE, - }: PathCacheOptions = {}) { - this.#ttlMs = ttlMs; - this.#maxSize = maxSize; - } - - async resolveProjectPath( - projectPath: string | null | undefined, - ): Promise { - if (!projectPath) return { available: false, effectiveProjectKey: null }; - - const entry = this.#cache.get(projectPath); - const now = Date.now(); - - if (entry && now - entry.checkedAt < this.#ttlMs) { - return entry.status; - } - - const status = await PathCache.#resolvePath(projectPath); - this.#cache.delete(projectPath); - this.#cache.set(projectPath, { status, checkedAt: now }); - this.#pruneIfNeeded(); - return status; - } - - async resolveProjectPaths( - projectPaths: readonly string[], - concurrency = 8, - ): Promise> { - const uniquePaths = [...new Set(projectPaths.filter(Boolean))]; - const resolved = await mapWithConcurrencyResult( - uniquePaths, - concurrency, - async (projectPath) => - [projectPath, await this.resolveProjectPath(projectPath)] as const, - ); - return new Map(resolved); - } - - #pruneIfNeeded() { - if (this.#cache.size <= this.#maxSize) return; - const toDelete = this.#cache.size - this.#maxSize; - let deleted = 0; - for (const key of this.#cache.keys()) { - if (deleted >= toDelete) break; - this.#cache.delete(key); - deleted++; - } - } - - static async #resolvePath(projectPath: string): Promise { - try { - const canonical = await assertRealWithinProjectBase(projectPath); - const stat = await fs.stat(canonical); - return stat.isDirectory() - ? { available: true, effectiveProjectKey: canonical } - : { available: false, effectiveProjectKey: null }; - } catch (error: unknown) { - const code = (error as NodeJS.ErrnoException)?.code; - if ( - isProjectBoundaryError(error) || - code === 'ENOENT' || - code === 'ENOTDIR' || - code === 'EACCES' || - code === 'EPERM' || - code === 'ELOOP' - ) { - return { available: false, effectiveProjectKey: null }; - } - throw error; - } - } -} diff --git a/server/chats/store.ts b/server/chats/store.ts index c1545e6f9..7e36ff2c1 100644 --- a/server/chats/store.ts +++ b/server/chats/store.ts @@ -740,13 +740,19 @@ export class ChatRegistry extends EventEmitter implements IC restoreIfCurrent(); throw error; } - this.#emitChatProjectPathUpdated({ - chatId: update.chatId, - projectPath: update.projectPath, - effectiveProjectKey: update.effectiveProjectKey, - previousProjectPath: update.previousProjectPath, - previousEffectiveProjectKey: update.previousEffectiveProjectKey, - }); + try { + this.#emitChatProjectPathUpdated({ + chatId: update.chatId, + projectPath: update.projectPath, + effectiveProjectKey: update.effectiveProjectKey, + previousProjectPath: update.previousProjectPath, + }); + } catch (error) { + logger.warn('Project-path update publication failed after persistence', { + chatId: id, + error: error instanceof Error ? error.message : String(error), + }); + } return { id, ...cloneRegistryEntry(existing) }; } diff --git a/server/commands/__tests__/chat-command-service.test.js b/server/commands/__tests__/chat-command-service.test.js index 635f55568..f321bd48e 100644 --- a/server/commands/__tests__/chat-command-service.test.js +++ b/server/commands/__tests__/chat-command-service.test.js @@ -15,6 +15,7 @@ import { GoalControlDeliveryError, SteerDeliveryError, DomainError, + ProjectUnavailableError, } from '../../lib/domain-error.js'; import { QueueEntryMutationError, @@ -143,7 +144,6 @@ function projectedChat(chatId, projectPath = '/repo', source = {}) { agentOwnershipEpoch: source.agentOwnershipEpoch ?? 'epoch-1', title: 'Chat', projectPath, - effectiveProjectKey: projectPath, orderGroup: 'normal', tags: [], activity: { createdAt: null, lastActivityAt: null, lastReadAt: null }, @@ -481,6 +481,7 @@ function makeService(overrides = {}) { return { chatId, reservationId: `snapshot-${chatId}` }; }), releaseTranscriptSnapshot: mock(() => Promise.resolve(undefined)), + discardPendingChatInput: mock(() => Promise.resolve(storedQueue())), reserveDirectTurn: mock((chatId) => directReservation(chatId)), assertDirectTurnReservationActive: mock(() => undefined), releaseDirectTurn: mock(() => Promise.resolve(undefined)), @@ -693,14 +694,6 @@ function makeService(overrides = {}) { return Promise.resolve(projectedChat(chatId, chat?.projectPath ?? '/repo', chat)); }), }; - const pathCache = { - resolveProjectPath: mock((projectPath) => - Promise.resolve({ - available: true, - effectiveProjectKey: projectPath, - }), - ), - }; const fileMentions = overrides.fileMentions ?? { resolve: mock(async (command) => command), }; @@ -716,7 +709,6 @@ function makeService(overrides = {}) { agents, fileMentions, chatListProjector, - pathCache, forkChatFileCopy, transcripts, ownership, @@ -741,7 +733,6 @@ function makeService(overrides = {}) { ledger, sessions, chatListProjector, - pathCache, ownership, handoffs, handoffPreparations, @@ -751,13 +742,18 @@ function makeService(overrides = {}) { function makeInputProjection(overrides = {}) { return { admitInput: mock(async () => ({ inserted: true })), + hasMatchingInput: mock(async () => false), admitQueuedInput: mock(() => ({ inserted: true })), discardPreparedInput: mock(() => undefined), ...overrides, }; } -function makeRealQueue(inputProjection, turnRunnerOverrides = {}) { +function makeRealQueue( + inputProjection, + turnRunnerOverrides = {}, + projectAdmission = { assertAvailable: mock(async () => undefined) }, +) { return new ChatExecutionCoordinator( workspaceDir, { @@ -771,6 +767,9 @@ function makeRealQueue(inputProjection, turnRunnerOverrides = {}) { () => ({}), () => true, new InMemoryChatExecutionControlRepository('server-instance-test'), + { + projectAdmission, + }, ); } @@ -2066,6 +2065,80 @@ describe('ChatCommandService', () => { }); }); + it('retries the same unavailable-project command until the folder is restored', async () => { + let available = false; + const projectAdmission = { + assertAvailable: mock(async () => { + if (!available) throw new ProjectUnavailableError('/repo', 'not-found'); + }), + }; + const inputProjection = makeInputProjection(); + const runAgentTurn = mock(async () => undefined); + const queueService = makeRealQueue(inputProjection, { runAgentTurn }, projectAdmission); + const { service, ledger } = makeService({ queueService }); + const input = { + chatId: SOURCE_CHAT_ID, + command: 'continue after restore', + clientRequestId: 'req-project-restore', + clientMessageId: 'msg-project-restore', + }; + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(service.submitRun(input)).rejects.toMatchObject({ + code: 'PROJECT_UNAVAILABLE', + status: 409, + retryable: false, + }); + } + expect(await readLedgerRecord(ledger, 'agent-run', input.clientRequestId)).toMatchObject({ + status: 'failed', + errorCode: 'PRE_SCHEDULE_FAILED', + }); + await expect(service.submitRun({ ...input, command: 'changed content' })).rejects.toMatchObject({ + code: 'IDEMPOTENCY_CONFLICT', + }); + + available = true; + await expect(service.submitRun(input)).resolves.toMatchObject({ status: 'accepted' }); + await queueService.waitForDispatches(); + + expect(projectAdmission.assertAvailable).toHaveBeenCalledTimes(3); + expect(inputProjection.admitInput).toHaveBeenCalledTimes(1); + expect(runAgentTurn).toHaveBeenCalledTimes(1); + }); + + it('settles a new request for matching committed input as a duplicate before rechecking the project', async () => { + let matching = false; + const inputProjection = makeInputProjection({ + hasMatchingInput: mock(() => matching), + }); + const projectAdmission = { assertAvailable: mock(async () => undefined) }; + const queueService = makeRealQueue(inputProjection, {}, projectAdmission); + const { service } = makeService({ queueService }); + const firstInput = { + chatId: SOURCE_CHAT_ID, + command: 'committed input', + clientRequestId: 'req-committed-first', + clientMessageId: 'msg-committed', + }; + + const first = await service.submitRun(firstInput); + await queueService.waitForDispatches(); + queueService.onAgentTurnTerminal(SOURCE_CHAT_ID, { + clientRequestId: firstInput.clientRequestId, + turnId: first.turnId, + }); + matching = true; + + await expect(service.submitRun({ + ...firstInput, + clientRequestId: 'req-committed-second', + })).resolves.toMatchObject({ status: 'duplicate' }); + + expect(inputProjection.admitInput).toHaveBeenCalledTimes(1); + expect(projectAdmission.assertAvailable).toHaveBeenCalledTimes(1); + }); + it('replays an admitted run before revalidating changed persisted defaults', async () => { const { service, chats, queue } = makeService({ session: { permissionMode: 'default' }, @@ -5168,7 +5241,6 @@ describe('ChatCommandService', () => { projectPath: realNextPath, effectiveProjectKey: realNextPath, previousProjectPath: '/repo', - previousEffectiveProjectKey: '/repo', }); expect(agents.prepareProjectPathUpdate).toHaveBeenCalledWith( 'claude', @@ -5186,7 +5258,6 @@ describe('ChatCommandService', () => { projectPath: realNextPath, effectiveProjectKey: realNextPath, previousProjectPath: '/repo', - previousEffectiveProjectKey: '/repo', }), { flush: true }, ); @@ -5194,7 +5265,7 @@ describe('ChatCommandService', () => { }); it('maps unresolvable project path updates to not found', async () => { - const { service, chats, agents } = makeService(); + const { service, chats, agents, queue } = makeService(); const projectPaths = await createUnresolvableProjectPaths(); for (const projectPath of projectPaths) { @@ -5206,6 +5277,7 @@ describe('ChatCommandService', () => { expect(chats.updateProjectPath).not.toHaveBeenCalled(); expect(agents.prepareProjectPathUpdate).not.toHaveBeenCalled(); + expect(queue.discardPendingChatInput).not.toHaveBeenCalled(); }); it('persists a prepared native session before provider cleanup', async () => { @@ -5305,6 +5377,7 @@ describe('ChatCommandService', () => { expect(rollback).toHaveBeenCalledTimes(1); expect(commit).not.toHaveBeenCalled(); + expect(fixture.queue.discardPendingChatInput).not.toHaveBeenCalled(); }); it('rolls back an unchanged native binding when registry persistence fails', async () => { @@ -5540,7 +5613,7 @@ describe('ChatCommandService', () => { expect(agents.prepareProjectPathUpdate).not.toHaveBeenCalled(); }); - it('rejects project path updates while a queued turn is waiting', async () => { + it('clears a queued turn after a project path update commits', async () => { const { service, queue, agents } = makeService(); const nextPath = path.join(projectBaseDir, 'repo-worktree'); await fs.mkdir(nextPath, { recursive: true }); @@ -5548,17 +5621,16 @@ describe('ChatCommandService', () => { queueEntry('queued-1', 'continue', 'queued'), ])); - await expect( - service.updateProjectPath({ - chatId: SOURCE_CHAT_ID, - projectPath: nextPath, - }), - ).rejects.toMatchObject({ code: 'CHAT_NOT_IDLE', status: 409 }); + await expect(service.updateProjectPath({ + chatId: SOURCE_CHAT_ID, + projectPath: nextPath, + })).resolves.toMatchObject({ success: true }); - expect(agents.prepareProjectPathUpdate).not.toHaveBeenCalled(); + expect(agents.prepareProjectPathUpdate).toHaveBeenCalledTimes(1); + expect(queue.discardPendingChatInput).toHaveBeenCalledWith(SOURCE_CHAT_ID); }); - it('rejects project path updates while a queued entry is steering', async () => { + it('clears a steering queue entry after a project path update commits', async () => { const { service, queue, agents } = makeService(); const nextPath = path.join(projectBaseDir, 'repo-worktree'); await fs.mkdir(nextPath, { recursive: true }); @@ -5566,17 +5638,16 @@ describe('ChatCommandService', () => { queueEntry('steering-1', 'continue', 'steering'), ])); - await expect( - service.updateProjectPath({ - chatId: SOURCE_CHAT_ID, - projectPath: nextPath, - }), - ).rejects.toMatchObject({ code: 'CHAT_NOT_IDLE', status: 409 }); + await expect(service.updateProjectPath({ + chatId: SOURCE_CHAT_ID, + projectPath: nextPath, + })).resolves.toMatchObject({ success: true }); - expect(agents.prepareProjectPathUpdate).not.toHaveBeenCalled(); + expect(agents.prepareProjectPathUpdate).toHaveBeenCalledTimes(1); + expect(queue.discardPendingChatInput).toHaveBeenCalledWith(SOURCE_CHAT_ID); }); - it('rejects project path updates while private control input is waiting', async () => { + it('clears private control input after a project path update commits', async () => { const { service, queue, agents } = makeService(); const nextPath = path.join(projectBaseDir, 'repo-worktree'); await fs.mkdir(nextPath, { recursive: true }); @@ -5584,13 +5655,78 @@ describe('ChatCommandService', () => { controlEntries: [controlEntry('control-1')], })); - await expect( - service.updateProjectPath({ - chatId: SOURCE_CHAT_ID, - projectPath: nextPath, - }), - ).rejects.toMatchObject({ code: 'CHAT_NOT_IDLE', status: 409 }); + await expect(service.updateProjectPath({ + chatId: SOURCE_CHAT_ID, + projectPath: nextPath, + })).resolves.toMatchObject({ success: true }); + + expect(agents.prepareProjectPathUpdate).toHaveBeenCalledTimes(1); + expect(queue.discardPendingChatInput).toHaveBeenCalledWith(SOURCE_CHAT_ID); + }); + + it('discards pending input but propagates an authoritative session commit failure', async () => { + const queueService = makeRealQueue(makeInputProjection()); + const publishSessionFact = mock(() => { + throw new Error('publication failed'); + }); + const relocated = { + ownerId: 'claude', + schemaVersion: 1, + value: { path: '/synthetic/relocated.jsonl', agentSessionId: 'agent-1' }, + }; + const { service, chats } = makeService({ + queueService, + agents: { + prepareProjectPathUpdate: mock(async () => ({ + nativeSession: relocated, + commit: mock(async () => undefined), + rollback: mock(async () => undefined), + })), + publishSessionFact, + }, + }); + await queueService.createChatQueueEntry(SOURCE_CHAT_ID, 'queued work'); + await queueService.pauseChatQueue(SOURCE_CHAT_ID); + const { id: _id, ...pendingControl } = controlEntry('control-pending'); + await queueService.deliverInterAgentControlInput( + SOURCE_CHAT_ID, + pendingControl, + new AbortController().signal, + ); + await queueService.waitForDispatches(); + const nextPath = path.join(projectBaseDir, 'real-discard'); + await fs.mkdir(nextPath, { recursive: true }); + await expect(service.updateProjectPath({ + chatId: SOURCE_CHAT_ID, + projectPath: nextPath, + })).rejects.toThrow('publication failed'); + + expect(chats.getChat(SOURCE_CHAT_ID).projectPath).toBe(nextPath); + expect(await queueService.readChatExecutionControl(SOURCE_CHAT_ID)).toMatchObject({ + entries: [], + controlEntries: [], + pause: null, + }); + expect(publishSessionFact).toHaveBeenCalledTimes(1); + }); + + it('preserves pending work when an unchanged project path is submitted', async () => { + const { service, queue, agents } = makeService({ + session: { projectPath: projectBaseDir }, + }); + + await expect(service.updateProjectPath({ + chatId: SOURCE_CHAT_ID, + projectPath: projectBaseDir, + })).resolves.toMatchObject({ + success: true, + projectPath: projectBaseDir, + previousProjectPath: projectBaseDir, + }); + + expect(queue.reserveTranscriptSnapshot).not.toHaveBeenCalled(); + expect(queue.discardPendingChatInput).not.toHaveBeenCalled(); expect(agents.prepareProjectPathUpdate).not.toHaveBeenCalled(); }); @@ -5672,22 +5808,9 @@ describe('ChatCommandService', () => { let compactTurn; const compactStarted = deferred(); const releaseCompact = deferred(); - const queueReadStarted = deferred(); - const releaseQueueRead = deferred(); const queueService = makeRealQueue(inputProjection, { isChatRunning: mock(() => runtimeRunning), }); - const readChatExecutionControl = queueService.readChatExecutionControl.bind(queueService); - let holdNextQueueRead = false; - queueService.readChatExecutionControl = mock(async (...args) => { - const queue = await readChatExecutionControl(...args); - if (holdNextQueueRead) { - holdNextQueueRead = false; - queueReadStarted.resolve(); - await releaseQueueRead.promise; - } - return queue; - }); const { service, agents } = makeService({ queueService, agents: { @@ -5715,26 +5838,18 @@ describe('ChatCommandService', () => { await compactStarted.promise; expect(queueService.ownsExecution(SOURCE_CHAT_ID)).toBe(true); - holdNextQueueRead = true; pathUpdate = service.updateProjectPath({ chatId: SOURCE_CHAT_ID, projectPath: nextPath, }); - await waitForCheckpoint(queueReadStarted.promise, pathUpdate, 'project path update'); - - releaseCompact.resolve(); - await service.waitForBackgroundTasks(); - // The direct reservation is gone but its attempt is retained, so the chat still owns - // execution across the handoff; that is one question now, not two. - expect(queueService.ownsExecution(SOURCE_CHAT_ID)).toBe(true); - - releaseQueueRead.resolve(); await expect(pathUpdate).rejects.toMatchObject({ code: 'CHAT_NOT_IDLE', message: 'Cannot update project path while a turn is being prepared or finalized', }); expect(agents.prepareProjectPathUpdate).not.toHaveBeenCalled(); + releaseCompact.resolve(); + await service.waitForBackgroundTasks(); runtimeRunning = false; queueService.onAgentTurnTerminal(SOURCE_CHAT_ID, compactTurn); expect(queueService.ownsExecution(SOURCE_CHAT_ID)).toBe(false); @@ -5744,7 +5859,6 @@ describe('ChatCommandService', () => { })).resolves.toMatchObject({ success: true }); } finally { releaseCompact.resolve(); - releaseQueueRead.resolve(); await service.waitForBackgroundTasks(); runtimeRunning = false; if (compactTurn) queueService.onAgentTurnTerminal(SOURCE_CHAT_ID, compactTurn); @@ -5753,7 +5867,7 @@ describe('ChatCommandService', () => { }); it('does not persist a project path when provider preparation fails', async () => { - const { service, chats } = makeService({ + const { service, chats, queue } = makeService({ agents: { prepareProjectPathUpdate: mock(async () => { throw new Error('provider is not idle'); @@ -5772,6 +5886,7 @@ describe('ChatCommandService', () => { message: 'provider is not idle', }); expect(chats.updateProjectPath).not.toHaveBeenCalled(); + expect(queue.discardPendingChatInput).not.toHaveBeenCalled(); }); it('serializes new direct admission behind project path preparation', async () => { diff --git a/server/commands/command-support.ts b/server/commands/command-support.ts index 2ff919a4b..55ed76893 100644 --- a/server/commands/command-support.ts +++ b/server/commands/command-support.ts @@ -46,7 +46,6 @@ import type { AgentOwnershipJournal } from '../chats/agent-ownership-journal.js' import type { AgentHandoffService } from '../agents/agent-handoff-service.js'; import type { ChatListProjector } from '../chats/chat-list-projector.js'; import type { ForkChatFileCopyResult } from '../chats/fork-chat.js'; -import type { PathCache } from '../chats/path-cache.js'; import type { RecentTitleIconSource } from '../chats/recent-title-icons.js'; import type { ChatRegistryEntry, IChatRegistry } from '../chats/store.js'; import type { ChatTransientFeedStore } from '../chats/chat-transient-feed.js'; @@ -162,7 +161,6 @@ export interface ChatCommandServiceDeps { readForkedNativeHistory: ForkedNativeHistoryReaderDep; transcripts: TranscriptLedgerService; chatListProjector: Pick; - pathCache: Pick; ownership: Pick; handoffs: Pick< AgentHandoffService, @@ -441,7 +439,7 @@ export class CommandSupport { ); } - projectCommandChatIfPresent( + async projectCommandChatIfPresent( chatId: string, ): Promise { return this.deps.chatListProjector.buildOne(chatId); @@ -553,7 +551,7 @@ export class CommandSupport { if (input.images !== undefined) options.images = input.images; try { - await this.deps.queue.scheduleDirectInput({ + const scheduleOutcome = await this.deps.queue.scheduleDirectInput({ command: { key: ledger.record.key, chatId: input.chatId, @@ -566,6 +564,13 @@ export class CommandSupport { settlement: this.settlement, preparation, }); + if (scheduleOutcome === 'duplicate') { + return this.agentTurnResultWithOptionalChat( + ledger.record, + 'duplicate', + Boolean(input.handoff), + ); + } } catch (error) { throw await withCurrentExecutionControl({ chatId: input.chatId, diff --git a/server/commands/session-commands.ts b/server/commands/session-commands.ts index f69c74ed8..c02d2d4e6 100644 --- a/server/commands/session-commands.ts +++ b/server/commands/session-commands.ts @@ -11,10 +11,9 @@ import { prepareAgentHandoffCommand } from '../agents/agent-handoff-command.js'; import { runOptionsForCommand } from '../agents/agent-run-command-input.js'; import { runProjectPathUpdateTransaction } from '../agents/project-path-update-transaction.js'; import type { StartedAgentSession } from '../agents/session-types.js'; -import { - hasPendingTurnInput, - toClientChatExecutionControlState, -} from '../chat-execution/control-state.ts'; +import { toClientChatExecutionControlState } from '../chat-execution/control-state.ts'; +import type { TranscriptSnapshotReservation } from '../chat-execution/types.ts'; +import { DomainError } from '../lib/domain-error.js'; import { createLogger } from '../lib/log.js'; import { withCurrentExecutionControl } from '../lib/command-execution-control-error.js'; import { resolveUpdatedProjectPath } from '../lib/command-project-path.js'; @@ -446,7 +445,7 @@ export class SessionCommands { } private async updateProjectPathLocked(input: UpdateProjectPathInput): Promise { - let chat = this.deps.chats.getChat(input.chatId); + const chat = this.deps.chats.getChat(input.chatId); if (!chat) { throw new CommandValidationError('SESSION_NOT_FOUND', 'Session not found', 404); } @@ -458,7 +457,6 @@ export class SessionCommands { ); } - const previousStatus = await this.deps.pathCache.resolveProjectPath(chat.projectPath); const nextProjectPath = await resolveUpdatedProjectPath(input.projectPath); const effectiveProjectKey = nextProjectPath; if (nextProjectPath === chat.projectPath) { @@ -468,78 +466,81 @@ export class SessionCommands { projectPath: chat.projectPath, effectiveProjectKey, previousProjectPath: chat.projectPath, - previousEffectiveProjectKey: previousStatus.effectiveProjectKey, }; } - await this.assertChatIdleForProjectPathUpdate(input.chatId, chat); - await this.deps.agents.currentTranscriptViewId(input.chatId); - const refreshedChat = this.deps.chats.getChat(input.chatId); - if (!refreshedChat) { - throw new CommandValidationError('SESSION_NOT_FOUND', 'Session not found', 404); - } - chat = refreshedChat; - const nativeSession = await this.nativeSessionForProjectPathUpdate(input.chatId, chat); + const reservation = this.reserveProjectPathUpdate(input.chatId); + try { + await this.assertChatIdleForProjectPathUpdate(chat); + await this.deps.agents.currentTranscriptViewId(input.chatId); + const refreshedChat = this.deps.chats.getChat(input.chatId); + if (!refreshedChat) { + throw new CommandValidationError('SESSION_NOT_FOUND', 'Session not found', 404); + } + const activeChat = refreshedChat; + const nativeSession = await this.nativeSessionForProjectPathUpdate(input.chatId, activeChat); - const event = { - chatId: input.chatId, - projectPath: nextProjectPath, - effectiveProjectKey, - previousProjectPath: chat.projectPath, - previousEffectiveProjectKey: previousStatus.effectiveProjectKey, - }; - let relocatedSession: StartedAgentSession | null = null; - const updated = await runProjectPathUpdateTransaction({ - chatId: input.chatId, - agentId: chat.agentId, - fallbackNativeSession: - nativeSession !== chat.nativeSession ? nativeSession : undefined, - prepare: () => this.deps.agents.prepareProjectPathUpdate(chat.agentId, { + const event = { chatId: input.chatId, - agentSessionId: chat.agentSessionId, - previousProjectPath: chat.projectPath, - nextProjectPath, - nativeSession, - }), - persist: async (nextNativeSession) => { - const persisted = await this.deps.chats.updateProjectPath( - input.chatId, - { - ...event, - ...(nextNativeSession !== undefined - ? { nativeSession: nextNativeSession } - : {}), - }, - { flush: true }, - ); - if (persisted?.agentSessionId && nextNativeSession !== undefined) { - relocatedSession = { - agentSessionId: persisted.agentSessionId, - nativeSession: nextNativeSession, - nativeSeedReceipt: persisted.nativeSeedReceipt ?? null, - }; - } - return persisted; - }, - logger, - }); - if (!updated) { - throw new CommandValidationError('SESSION_NOT_FOUND', 'Session not found', 404); - } - if (relocatedSession) { - this.deps.agents.publishSessionFact(input.chatId, relocatedSession); + projectPath: nextProjectPath, + effectiveProjectKey, + previousProjectPath: activeChat.projectPath, + }; + let relocatedSession: StartedAgentSession | null = null; + const updated = await runProjectPathUpdateTransaction({ + chatId: input.chatId, + agentId: activeChat.agentId, + fallbackNativeSession: + nativeSession !== activeChat.nativeSession ? nativeSession : undefined, + prepare: () => this.deps.agents.prepareProjectPathUpdate(activeChat.agentId, { + chatId: input.chatId, + agentSessionId: activeChat.agentSessionId, + previousProjectPath: activeChat.projectPath, + nextProjectPath, + nativeSession, + }), + persist: async (nextNativeSession) => { + const persisted = await this.deps.chats.updateProjectPath( + input.chatId, + { + ...event, + ...(nextNativeSession !== undefined + ? { nativeSession: nextNativeSession } + : {}), + }, + { flush: true }, + ); + if (persisted?.agentSessionId && nextNativeSession !== undefined) { + relocatedSession = { + agentSessionId: persisted.agentSessionId, + nativeSession: nextNativeSession, + nativeSeedReceipt: persisted.nativeSeedReceipt ?? null, + }; + } + return persisted; + }, + logger, + }); + if (!updated) { + throw new CommandValidationError('SESSION_NOT_FOUND', 'Session not found', 404); + } + await this.deps.queue.discardPendingChatInput(input.chatId); + if (relocatedSession) { + this.deps.agents.publishSessionFact(input.chatId, relocatedSession); + } + return { + success: true, + chatId: input.chatId, + projectPath: updated.projectPath, + effectiveProjectKey, + previousProjectPath: event.previousProjectPath, + }; + } finally { + await this.deps.queue.releaseTranscriptSnapshot(reservation); } - return { - success: true, - chatId: input.chatId, - projectPath: updated.projectPath, - effectiveProjectKey, - previousProjectPath: event.previousProjectPath, - previousEffectiveProjectKey: event.previousEffectiveProjectKey, - }; } - private async assertChatIdleForProjectPathUpdate(chatId: string, chat: ChatRegistryEntry): Promise { + private async assertChatIdleForProjectPathUpdate(chat: ChatRegistryEntry): Promise { if (chat.agentSessionId && this.deps.agents.isAgentSessionRunning(chat.agentId, chat.agentSessionId)) { throw new CommandValidationError( 'CHAT_NOT_IDLE', @@ -549,17 +550,13 @@ export class SessionCommands { ); } - const queue = await this.deps.queue.readChatExecutionControl(chatId); - if (hasPendingTurnInput(queue)) { - throw new CommandValidationError( - 'CHAT_NOT_IDLE', - 'Clear or run pending messages before updating the project path', - 409, - true, - ); - } + } - if (this.deps.queue.ownsExecution(chatId)) { + private reserveProjectPathUpdate(chatId: string): TranscriptSnapshotReservation { + try { + return this.deps.queue.reserveTranscriptSnapshot(chatId); + } catch (error) { + if (!(error instanceof DomainError) || error.code !== 'SESSION_BUSY') throw error; throw new CommandValidationError( 'CHAT_NOT_IDLE', 'Cannot update project path while a turn is being prepared or finalized', @@ -567,7 +564,6 @@ export class SessionCommands { true, ); } - } private async nativeSessionForProjectPathUpdate( diff --git a/server/lib/__tests__/command-project-path.test.js b/server/lib/__tests__/command-project-path.test.js new file mode 100644 index 000000000..4634b2757 --- /dev/null +++ b/server/lib/__tests__/command-project-path.test.js @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'bun:test'; +import { + resolveStartProjectPath, + resolveUpdatedProjectPath, +} from '../command-project-path.ts'; + +const PROJECT_PATH = '/workspace/project'; + +describe('command project path resolution', () => { + it('returns the canonical path from a successful inspection', async () => { + const inspect = async () => ({ + kind: 'available', + effectiveProjectKey: '/real/project', + }); + + await expect(resolveStartProjectPath(PROJECT_PATH, inspect)).resolves.toBe('/real/project'); + await expect(resolveUpdatedProjectPath(PROJECT_PATH, inspect)).resolves.toBe('/real/project'); + }); + + it('preserves caller-specific errors for every unavailable reason', async () => { + const cases = [ + { + reason: 'not-found', + start: { + code: 'VALIDATION_FAILED', + status: 404, + message: `Project path not found: ${PROJECT_PATH}`, + }, + update: { + code: 'PROJECT_PATH_NOT_FOUND', + status: 404, + message: `Project path not found: ${PROJECT_PATH}`, + }, + }, + { + reason: 'not-a-directory', + start: { + code: 'VALIDATION_FAILED', + status: 400, + message: `Project path is not a directory: ${PROJECT_PATH}`, + }, + update: { + code: 'PROJECT_PATH_NOT_DIRECTORY', + status: 400, + message: `Project path is not a directory: ${PROJECT_PATH}`, + }, + }, + { + reason: 'outside-base', + start: { + code: 'PROJECT_PATH_OUTSIDE_BASE', + status: 403, + message: 'Project path is outside the allowed base directory', + }, + update: { + code: 'PROJECT_PATH_OUTSIDE_BASE', + status: 403, + message: 'Project path is outside the allowed base directory', + }, + }, + { + reason: 'permission-denied', + start: { + code: 'VALIDATION_FAILED', + status: 403, + message: `Project folder cannot be accessed: ${PROJECT_PATH}`, + }, + update: { + code: 'VALIDATION_FAILED', + status: 403, + message: `Project folder cannot be accessed: ${PROJECT_PATH}`, + }, + }, + ]; + + for (const entry of cases) { + const inspect = async () => ({ kind: 'unavailable', reason: entry.reason }); + await expect(resolveStartProjectPath(PROJECT_PATH, inspect)).rejects.toMatchObject(entry.start); + await expect(resolveUpdatedProjectPath(PROJECT_PATH, inspect)).rejects.toMatchObject( + entry.update, + ); + } + }); +}); diff --git a/server/lib/command-project-path.ts b/server/lib/command-project-path.ts index c6a221284..93f1da481 100644 --- a/server/lib/command-project-path.ts +++ b/server/lib/command-project-path.ts @@ -1,39 +1,25 @@ -import { promises as fs } from 'node:fs'; import { CommandValidationError } from './command-validation-error.js'; -import { hasNodeErrorCode } from './errors.js'; -import { assertRealWithinProjectBase, isProjectBoundaryError } from './path-boundary.js'; +import type { ProjectUnavailableReason } from '../../common/project-resolution.js'; +import { inspectProjectDirectory } from '../projects/project-directory-service.js'; -export async function resolveStartProjectPath(projectPath: string | undefined): Promise { +export async function resolveStartProjectPath( + projectPath: string | undefined, + inspect = inspectProjectDirectory, +): Promise { const requestedPath = requiredProjectPath(projectPath); - const resolvedPath = await resolveCanonicalProjectPath(requestedPath, 'VALIDATION_FAILED'); - try { - await fs.access(resolvedPath); - } catch { - throw projectPathNotFound('VALIDATION_FAILED', resolvedPath); - } - return resolvedPath; + const resolution = await inspect(requestedPath); + if (resolution.kind === 'unavailable') throw startPathError(requestedPath, resolution.reason); + return resolution.effectiveProjectKey; } -export async function resolveUpdatedProjectPath(projectPath: string): Promise { +export async function resolveUpdatedProjectPath( + projectPath: string, + inspect = inspectProjectDirectory, +): Promise { const requestedPath = requiredProjectPath(projectPath); - const resolvedPath = await resolveCanonicalProjectPath(requestedPath, 'PROJECT_PATH_NOT_FOUND'); - let stat; - try { - stat = await fs.stat(resolvedPath); - } catch (error) { - if (hasNodeErrorCode(error, 'ENOENT') || hasNodeErrorCode(error, 'ENOTDIR')) { - throw projectPathNotFound('PROJECT_PATH_NOT_FOUND', resolvedPath); - } - throw error; - } - if (!stat.isDirectory()) { - throw new CommandValidationError( - 'PROJECT_PATH_NOT_DIRECTORY', - `Project path is not a directory: ${resolvedPath}`, - 400, - ); - } - return resolvedPath; + const resolution = await inspect(requestedPath); + if (resolution.kind === 'unavailable') throw updatePathError(requestedPath, resolution.reason); + return resolution.effectiveProjectKey; } function requiredProjectPath(projectPath: string | undefined): string { @@ -44,30 +30,57 @@ function requiredProjectPath(projectPath: string | undefined): string { return requestedPath; } -async function resolveCanonicalProjectPath( - requestedPath: string, - notFoundCode: 'VALIDATION_FAILED' | 'PROJECT_PATH_NOT_FOUND', -): Promise { - try { - return await assertRealWithinProjectBase(requestedPath); - } catch (error) { - if (isProjectBoundaryError(error)) { - throw new CommandValidationError( - 'PROJECT_PATH_OUTSIDE_BASE', - 'Project path is outside the allowed base directory', - 403, - ); - } - if (hasNodeErrorCode(error, 'ENOENT') || hasNodeErrorCode(error, 'ENOTDIR')) { - throw projectPathNotFound(notFoundCode, requestedPath); - } - throw error; - } -} - function projectPathNotFound( code: 'VALIDATION_FAILED' | 'PROJECT_PATH_NOT_FOUND', projectPath: string, ): CommandValidationError { return new CommandValidationError(code, `Project path not found: ${projectPath}`, 404); } + +function startPathError( + projectPath: string, + reason: ProjectUnavailableReason, +): CommandValidationError { + if (reason === 'not-found') return projectPathNotFound('VALIDATION_FAILED', projectPath); + if (reason === 'not-a-directory') { + return new CommandValidationError( + 'VALIDATION_FAILED', + `Project path is not a directory: ${projectPath}`, + 400, + ); + } + if (reason === 'outside-base') return outsideBaseError(); + return inaccessiblePathError(projectPath); +} + +function updatePathError( + projectPath: string, + reason: ProjectUnavailableReason, +): CommandValidationError { + if (reason === 'not-found') return projectPathNotFound('PROJECT_PATH_NOT_FOUND', projectPath); + if (reason === 'not-a-directory') { + return new CommandValidationError( + 'PROJECT_PATH_NOT_DIRECTORY', + `Project path is not a directory: ${projectPath}`, + 400, + ); + } + if (reason === 'outside-base') return outsideBaseError(); + return inaccessiblePathError(projectPath); +} + +function outsideBaseError(): CommandValidationError { + return new CommandValidationError( + 'PROJECT_PATH_OUTSIDE_BASE', + 'Project path is outside the allowed base directory', + 403, + ); +} + +function inaccessiblePathError(projectPath: string): CommandValidationError { + return new CommandValidationError( + 'VALIDATION_FAILED', + `Project folder cannot be accessed: ${projectPath}`, + 403, + ); +} diff --git a/server/lib/domain-error.ts b/server/lib/domain-error.ts index 149014f92..564178b3d 100644 --- a/server/lib/domain-error.ts +++ b/server/lib/domain-error.ts @@ -1,4 +1,5 @@ import type { ErrorCode } from '../../common/error-codes.ts'; +import type { ProjectUnavailableReason } from '../../common/project-resolution.ts'; import type { CommandErrorCode, SteerDeliveryOutcome, @@ -29,6 +30,21 @@ export class ValidationDomainError extends DomainError { } } +export class ProjectUnavailableError extends DomainError { + constructor( + readonly projectPath: string, + readonly reason: ProjectUnavailableReason, + ) { + super( + 'PROJECT_UNAVAILABLE', + `Project folder unavailable (${reason}): ${projectPath}`, + 409, + false, + ); + this.name = 'ProjectUnavailableError'; + } +} + export const STEER_NOT_DELIVERED_MESSAGE = 'Steering input was not delivered.'; export const STEER_OUTCOME_UNKNOWN_MESSAGE = 'Steering delivery could not be confirmed. Check the chat before sending it again.'; diff --git a/server/projects/__tests__/project-admission.test.js b/server/projects/__tests__/project-admission.test.js new file mode 100644 index 000000000..7ee691add --- /dev/null +++ b/server/projects/__tests__/project-admission.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it, mock } from 'bun:test'; +import { ProjectAdmission } from '../project-admission.ts'; + +describe('ProjectAdmission', () => { + it('checks the registered path freshly for each admission', async () => { + const inspect = mock(async () => ({ kind: 'available', effectiveProjectKey: '/real/project' })); + const admission = new ProjectAdmission({ + getChat: () => ({ projectPath: '/workspace/project' }), + }, inspect); + + await admission.assertAvailable('1783725900000800'); + await admission.assertAvailable('1783725900000800'); + + expect(inspect).toHaveBeenCalledTimes(2); + expect(inspect).toHaveBeenCalledWith('/workspace/project'); + }); + + it('preserves typed missing-chat and unavailable-project errors', async () => { + const missing = new ProjectAdmission({ getChat: () => null }); + await expect(missing.assertAvailable('1783725900000800')).rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', status: 404, + }); + + const unavailable = new ProjectAdmission( + { getChat: () => ({ projectPath: '/workspace/missing' }) }, + async () => ({ kind: 'unavailable', reason: 'not-found' }), + ); + await expect(unavailable.assertAvailable('1783725900000800')).rejects.toMatchObject({ + code: 'PROJECT_UNAVAILABLE', + status: 409, + retryable: false, + projectPath: '/workspace/missing', + reason: 'not-found', + }); + }); + + it('passes through unexpected inspection failures', async () => { + const failure = new Error('device failed'); + const admission = new ProjectAdmission( + { getChat: () => ({ projectPath: '/workspace/project' }) }, + async () => { throw failure; }, + ); + + await expect(admission.assertAvailable('1783725900000800')).rejects.toBe(failure); + }); +}); diff --git a/server/projects/__tests__/project-directory-service.test.js b/server/projects/__tests__/project-directory-service.test.js new file mode 100644 index 000000000..35883d70e --- /dev/null +++ b/server/projects/__tests__/project-directory-service.test.js @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { inspectProjectDirectory } from '../project-directory-service.ts'; + +describe('inspectProjectDirectory', () => { + let rootPath; + let basePath; + let priorBasePath; + + beforeEach(async () => { + priorBasePath = process.env.GARCON_PROJECT_BASE_DIR; + rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'garcon-project-directory-')); + basePath = path.join(rootPath, 'base'); + await fs.mkdir(basePath); + process.env.GARCON_PROJECT_BASE_DIR = basePath; + }); + + afterEach(async () => { + if (priorBasePath === undefined) delete process.env.GARCON_PROJECT_BASE_DIR; + else process.env.GARCON_PROJECT_BASE_DIR = priorBasePath; + await fs.rm(rootPath, { recursive: true, force: true }); + }); + + it('does not resolve a blank path to the project base', async () => { + await expect(inspectProjectDirectory(' ')).resolves.toEqual({ + kind: 'unavailable', reason: 'not-found', + }); + }); + + it('returns a canonical identity for an accessible directory', async () => { + const projectPath = path.join(basePath, 'project'); + const aliasPath = path.join(basePath, 'alias'); + await fs.mkdir(projectPath); + await fs.symlink(projectPath, aliasPath); + + await expect(inspectProjectDirectory(aliasPath)).resolves.toEqual({ + kind: 'available', + effectiveProjectKey: await fs.realpath(projectPath), + }); + }); + + it('classifies missing, non-directory, boundary, and permission failures', async () => { + const filePath = path.join(basePath, 'file'); + await fs.writeFile(filePath, 'file'); + await expect(inspectProjectDirectory(path.join(basePath, 'missing'))).resolves.toEqual({ + kind: 'unavailable', reason: 'not-found', + }); + await expect(inspectProjectDirectory(filePath)).resolves.toEqual({ + kind: 'unavailable', reason: 'not-a-directory', + }); + await expect(inspectProjectDirectory(path.dirname(basePath))).resolves.toEqual({ + kind: 'unavailable', reason: 'outside-base', + }); + await expect(inspectProjectDirectory(basePath, { + access: async () => { + const error = new Error('denied'); + error.code = 'EACCES'; + throw error; + }, + })).resolves.toEqual({ kind: 'unavailable', reason: 'permission-denied' }); + }); + + it('rejects symlinks that escape the project base', async () => { + const outsidePath = path.join(rootPath, 'outside'); + const aliasPath = path.join(basePath, 'alias'); + await fs.mkdir(outsidePath); + await fs.symlink(outsidePath, aliasPath); + + await expect(inspectProjectDirectory(aliasPath)).resolves.toEqual({ + kind: 'unavailable', reason: 'outside-base', + }); + }); + + it('classifies symlink loops as missing paths', async () => { + const firstPath = path.join(basePath, 'first'); + const secondPath = path.join(basePath, 'second'); + await fs.symlink(secondPath, firstPath); + await fs.symlink(firstPath, secondPath); + + await expect(inspectProjectDirectory(firstPath)).resolves.toEqual({ + kind: 'unavailable', reason: 'not-found', + }); + }); + + it('rejects unexpected filesystem failures', async () => { + const failure = new Error('device failed'); + await expect(inspectProjectDirectory(basePath, { + stat: async () => { throw failure; }, + })).rejects.toBe(failure); + }); +}); diff --git a/server/projects/project-admission.ts b/server/projects/project-admission.ts new file mode 100644 index 000000000..13a22272b --- /dev/null +++ b/server/projects/project-admission.ts @@ -0,0 +1,20 @@ +import type { IChatRegistry } from '../chats/store.js'; +import type { ProjectAdmissionPort } from '../chat-execution/types.js'; +import { DomainError, ProjectUnavailableError } from '../lib/domain-error.js'; +import { inspectProjectDirectory } from './project-directory-service.js'; + +export class ProjectAdmission implements ProjectAdmissionPort { + constructor( + private readonly registry: Pick, + private readonly inspect = inspectProjectDirectory, + ) {} + + async assertAvailable(chatId: string): Promise { + const chat = this.registry.getChat(chatId); + if (!chat) throw new DomainError('SESSION_NOT_FOUND', 'Session not found', 404); + const resolution = await this.inspect(chat.projectPath); + if (resolution.kind === 'unavailable') { + throw new ProjectUnavailableError(chat.projectPath, resolution.reason); + } + } +} diff --git a/server/projects/project-directory-service.ts b/server/projects/project-directory-service.ts new file mode 100644 index 000000000..c84d1cd9a --- /dev/null +++ b/server/projects/project-directory-service.ts @@ -0,0 +1,50 @@ +import { constants, promises as fs } from 'node:fs'; +import type { + ProjectResolution, + ProjectUnavailableReason, +} from '../../common/project-resolution.js'; +import { hasNodeErrorCode } from '../lib/errors.js'; +import { + assertRealWithinProjectBase, + isProjectBoundaryError, +} from '../lib/path-boundary.js'; + +export async function inspectProjectDirectory( + projectPath: string, + { + resolvePath = assertRealWithinProjectBase, + stat = fs.stat, + access = fs.access, + }: { + resolvePath?: typeof assertRealWithinProjectBase; + stat?: typeof fs.stat; + access?: typeof fs.access; + } = {}, +): Promise { + if (!projectPath.trim()) return { kind: 'unavailable', reason: 'not-found' }; + + try { + const canonical = await resolvePath(projectPath); + const status = await stat(canonical); + if (!status.isDirectory()) return { kind: 'unavailable', reason: 'not-a-directory' }; + await access(canonical, constants.R_OK | constants.X_OK); + return { kind: 'available', effectiveProjectKey: canonical }; + } catch (error) { + const reason = unavailableReason(error); + if (reason) return { kind: 'unavailable', reason }; + throw error; + } +} + +function unavailableReason(error: unknown): ProjectUnavailableReason | null { + if (isProjectBoundaryError(error)) return 'outside-base'; + if ( + hasNodeErrorCode(error, 'ENOENT') + || hasNodeErrorCode(error, 'ENOTDIR') + || hasNodeErrorCode(error, 'ELOOP') + ) return 'not-found'; + if (hasNodeErrorCode(error, 'EACCES') || hasNodeErrorCode(error, 'EPERM')) { + return 'permission-denied'; + } + return null; +} diff --git a/server/routes/__tests__/chat-routes-test-utils.js b/server/routes/__tests__/chat-routes-test-utils.js index ac7d19ce3..c06cf2eab 100644 --- a/server/routes/__tests__/chat-routes-test-utils.js +++ b/server/routes/__tests__/chat-routes-test-utils.js @@ -5,28 +5,12 @@ import { CommandLedger } from '../../commands/command-ledger.js'; import { ChatCommandService } from '../../commands/chat-command-service.js'; import { forkChatFileCopy } from '../../chats/fork-chat.js'; import { ChatListProjector } from '../../chats/chat-list-projector.js'; -import { mock } from 'bun:test'; export function createRouteCommandLedger(label = 'chat-routes') { return new CommandLedger(path.join(os.tmpdir(), `garcon-${label}-ledger-${randomUUID()}`)); } -export function createRoutePathCache() { - return { - resolveProjectPath: mock(async (projectPath) => ({ - available: true, - effectiveProjectKey: projectPath, - })), - resolveProjectPaths: mock(async (projectPaths) => new Map( - [...new Set(projectPaths)].map((projectPath) => [projectPath, { - available: true, - effectiveProjectKey: projectPath, - }]), - )), - }; -} - -export function createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }) { +export function createRouteChatListProjector({ registry, settings, metadata, agents }) { const processing = { phase(chatId) { const session = registry.getChat(chatId); @@ -40,7 +24,6 @@ export function createRouteChatListProjector({ registry, settings, metadata, age settings, metadata, processing, - pathCache, canReloadFromNativeHistory: () => false, }); } @@ -51,9 +34,8 @@ export function createRouteCommandService({ settings, metadata, agents, - commandLedger, + commandLedger, handoffs, - pathCache, chatListProjector, forkChatFileCopy: forkChatFileCopyOverride, ownership, @@ -114,7 +96,6 @@ export function createRouteCommandService({ return true; }, }, - pathCache: pathCache ?? createRoutePathCache(), chatListProjector: chatListProjector ?? { buildOne: async (chatId) => { const session = registry.getChat(chatId); @@ -132,7 +113,6 @@ export function createRouteCommandService({ }, title: 'Chat', projectPath: session.projectPath, - effectiveProjectKey: session.projectPath, orderGroup: 'normal', tags: session.tags ?? [], activity: { createdAt: null, lastActivityAt: null, lastReadAt: null }, diff --git a/server/routes/__tests__/chats-archive.test.js b/server/routes/__tests__/chats-archive.test.js index f9d678474..4044941e0 100644 --- a/server/routes/__tests__/chats-archive.test.js +++ b/server/routes/__tests__/chats-archive.test.js @@ -18,7 +18,7 @@ mock.module('../../chats/title-generator.js', () => ({ })); import createChatRoutes from '../chats.js'; -import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, createRoutePathCache } from './chat-routes-test-utils.js'; +import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService } from './chat-routes-test-utils.js'; const CHAT_ID = '1783725900000600'; const CHAT_ID_2 = '1783725900000601'; @@ -61,7 +61,6 @@ const settings = { toggleArchive: mock(() => Promise.resolve({ isArchived: true })), }; const queue = { deleteChatQueueFile: mock(() => Promise.resolve(undefined)) }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -83,14 +82,13 @@ const agents = { }; const commandLedger = createRouteCommandLedger('chats-archive'); -const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }); +const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents }); const chatsRoutes = createChatRoutes({ registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -102,7 +100,6 @@ const chatsRoutes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, chatListProjector, }), }); diff --git a/server/routes/__tests__/chats-command-routes.test.js b/server/routes/__tests__/chats-command-routes.test.js index 17f78a993..e1ed17d97 100644 --- a/server/routes/__tests__/chats-command-routes.test.js +++ b/server/routes/__tests__/chats-command-routes.test.js @@ -67,7 +67,6 @@ import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, - createRoutePathCache, } from './chat-routes-test-utils.js'; const CHAT_ID = '1783725900000700'; @@ -358,6 +357,7 @@ function createRouteAgent(sessionOverrides = {}) { return { chatId, reservationId: 'snapshot-reservation' }; }), releaseTranscriptSnapshot: mock(() => Promise.resolve(undefined)), + discardPendingChatInput: mock(() => Promise.resolve(storedQueue())), readChatExecutionControl: mock(() => Promise.resolve(storedQueue())), createChatQueueEntry: mock(() => Promise.resolve({ @@ -413,7 +413,6 @@ function createRouteAgent(sessionOverrides = {}) { }), waitForDispatches: mock(() => Promise.resolve(undefined)), }; - const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -467,14 +466,12 @@ function createRouteAgent(sessionOverrides = {}) { settings, metadata, agents, - pathCache, }); const routes = createChatRoutes({ registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -486,7 +483,6 @@ function createRouteAgent(sessionOverrides = {}) { metadata, agents, commandLedger, - pathCache, chatListProjector, forkChatFileCopy: async (args) => { await forkChatFileCopy(args); @@ -523,7 +519,6 @@ function createRouteAgent(sessionOverrides = {}) { registry, settings, queue, - pathCache, metadata, chatViews, agents, @@ -1735,7 +1730,6 @@ describe('REST chat command routes', () => { projectPath: realNextPath, effectiveProjectKey: realNextPath, previousProjectPath: '/workspace/project', - previousEffectiveProjectKey: '/workspace/project', }); expect(agent.agents.prepareProjectPathUpdate).toHaveBeenCalledWith( 'claude', @@ -1802,7 +1796,7 @@ describe('REST chat command routes', () => { expect(agent.registry.updateProjectPath).not.toHaveBeenCalled(); }); - it('PATCH /project-path rejects chats with queued messages', async () => { + it('PATCH /project-path clears queued messages after the update commits', async () => { const agent = createRouteAgent(); const nextPath = path.join(testBasePath, 'repo-worktree'); await fs.mkdir(nextPath, { recursive: true }); @@ -1816,8 +1810,9 @@ describe('REST chat command routes', () => { 'PATCH', ); - expect(response.status).toBe(409); - expect(body.errorCode).toBe('CHAT_NOT_IDLE'); - expect(agent.agents.prepareProjectPathUpdate).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body).toMatchObject({ success: true, projectPath: await fs.realpath(nextPath) }); + expect(agent.agents.prepareProjectPathUpdate).toHaveBeenCalledTimes(1); + expect(agent.queue.discardPendingChatInput).toHaveBeenCalledWith(CHAT_ID); }); }); diff --git a/server/routes/__tests__/chats-fork.test.js b/server/routes/__tests__/chats-fork.test.js index 4a52f10f8..f2e723378 100644 --- a/server/routes/__tests__/chats-fork.test.js +++ b/server/routes/__tests__/chats-fork.test.js @@ -20,7 +20,7 @@ mock.module('../../chats/fork-chat.js', () => ({ })); import createChatRoutes from '../chats.js'; -import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, createRoutePathCache } from './chat-routes-test-utils.js'; +import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService } from './chat-routes-test-utils.js'; import { DomainError } from '../../lib/domain-error.js'; const SOURCE_CHAT_ID = '1783725900000300'; @@ -59,7 +59,6 @@ const queue = { releaseTranscriptSnapshot: mock(() => Promise.resolve(undefined)), ownsExecution: mock(() => false), }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -91,14 +90,13 @@ const agents = { }; const commandLedger = createRouteCommandLedger('chats-fork'); -const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }); +const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents }); const chatsRoutes = createChatRoutes({ registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -110,7 +108,6 @@ const chatsRoutes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, chatListProjector, }), }); @@ -299,7 +296,6 @@ describe('POST /api/v1/chats/fork', () => { id: TARGET_CHAT_ID, agentId: 'test-agent', projectPath: '/proj', - effectiveProjectKey: '/proj', orderGroup: 'orphan', }); }); diff --git a/server/routes/__tests__/chats-last-selected.test.js b/server/routes/__tests__/chats-last-selected.test.js index 0da410639..2cff9631e 100644 --- a/server/routes/__tests__/chats-last-selected.test.js +++ b/server/routes/__tests__/chats-last-selected.test.js @@ -34,18 +34,6 @@ function createFixture() { abortForChatDeletion: mock(() => Promise.resolve(false)), deleteChatQueueFile: mock(() => Promise.resolve(undefined)), }; - const pathCache = { - resolveProjectPath: mock((projectPath) => Promise.resolve({ - available: true, - effectiveProjectKey: projectPath, - })), - resolveProjectPaths: mock((projectPaths) => Promise.resolve(new Map( - projectPaths.map((projectPath) => [projectPath, { - available: true, - effectiveProjectKey: projectPath, - }]), - ))), - }; const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -66,13 +54,12 @@ function createFixture() { isAgentSessionRunning: mock(() => false), }; const lastSelectedChat = new InMemoryLastSelectedChatState(); - const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }); + const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents }); const routes = createChatRoutes({ registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -81,7 +68,7 @@ function createFixture() { lastSelectedChat, }); - return { agents, lastSelectedChat, metadata, pathCache, registry, routes, settings }; + return { agents, lastSelectedChat, metadata, registry, routes, settings }; } function chatEntry(projectPath = '/proj') { @@ -131,20 +118,14 @@ describe('last selected chat routes', () => { expect(body.sessions).toHaveLength(1); }); - it('returns null when remembered chat is path-filtered but keeps memory', async () => { + it('returns a remembered chat even when its project path is unavailable', async () => { fixture.lastSelectedChat.setLastSelectedChatId(CHAT_ID); fixture.registry.listAllChats.mockImplementation(() => ({ [CHAT_ID]: chatEntry('/missing') })); - fixture.pathCache.resolveProjectPaths.mockImplementation((projectPaths) => Promise.resolve(new Map( - projectPaths.map((projectPath) => [projectPath, { - available: false, - effectiveProjectKey: null, - }]), - ))); const response = await fixture.routes['/api/v1/chats'].GET(); const body = await response.json(); - expect(body.lastSelectedChatId).toBeNull(); + expect(body.lastSelectedChatId).toBe(CHAT_ID); expect(fixture.lastSelectedChat.getLastSelectedChatId()).toBe(CHAT_ID); }); diff --git a/server/routes/__tests__/chats-messages.test.js b/server/routes/__tests__/chats-messages.test.js index 098cf02aa..f182089fa 100644 --- a/server/routes/__tests__/chats-messages.test.js +++ b/server/routes/__tests__/chats-messages.test.js @@ -21,7 +21,6 @@ import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, - createRoutePathCache, } from './chat-routes-test-utils.js'; const CHAT_ID = '1783725900000200'; @@ -86,7 +85,6 @@ function createRoutesFixture(overrides = {}) { ownsExecution: mock(() => false), deleteChatQueueFile: mock(async () => undefined), }; - const pathCache = createRoutePathCache(); const metadata = { listAllChatMetadata: mock(() => new Map()), getChatMetadata: mock(() => null), @@ -124,14 +122,12 @@ function createRoutesFixture(overrides = {}) { settings, metadata, agents, - pathCache, }); const routes = createChatRoutes({ registry, settings, queue, processing: overrides.processing ?? { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -143,7 +139,6 @@ function createRoutesFixture(overrides = {}) { metadata, agents, commandLedger, - pathCache, chatListProjector, ownership: overrides.ownership, }), diff --git a/server/routes/__tests__/chats-read.test.js b/server/routes/__tests__/chats-read.test.js index 560763dab..0054744e0 100644 --- a/server/routes/__tests__/chats-read.test.js +++ b/server/routes/__tests__/chats-read.test.js @@ -20,7 +20,6 @@ import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, - createRoutePathCache, } from './chat-routes-test-utils.js'; const CHAT_ID = '1783725900000400'; @@ -76,7 +75,6 @@ const settings = { })), }; const queue = { deleteChatQueueFile: mock(() => Promise.resolve(undefined)) }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -104,7 +102,6 @@ const chatListProjector = createRouteChatListProjector({ settings, metadata, agents, - pathCache, }); const chatsRoutes = createChatRoutes({ @@ -112,7 +109,6 @@ const chatsRoutes = createChatRoutes({ settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -124,7 +120,6 @@ const chatsRoutes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, chatListProjector, }), }); diff --git a/server/routes/__tests__/chats-reorder.test.js b/server/routes/__tests__/chats-reorder.test.js index c10b1e128..4d7749396 100644 --- a/server/routes/__tests__/chats-reorder.test.js +++ b/server/routes/__tests__/chats-reorder.test.js @@ -20,7 +20,6 @@ import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, - createRoutePathCache, } from './chat-routes-test-utils.js'; import { parseJsonBody } from '../../lib/http-request.js'; @@ -52,7 +51,6 @@ const settings = { sortChatOrder: mock(() => Promise.resolve({ changed: true })), }; const queue = { deleteChatQueueFile: mock(() => Promise.resolve(undefined)) }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -79,14 +77,12 @@ const chatListProjector = createRouteChatListProjector({ settings, metadata, agents, - pathCache, }); const chatsRoutes = createChatRoutes({ registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -98,7 +94,6 @@ const chatsRoutes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, chatListProjector, }), }); diff --git a/server/routes/__tests__/chats-search.test.js b/server/routes/__tests__/chats-search.test.js index 8ecd83664..ed214466a 100644 --- a/server/routes/__tests__/chats-search.test.js +++ b/server/routes/__tests__/chats-search.test.js @@ -6,7 +6,6 @@ import { TranscriptSearchUnavailableError } from '../../chats/search/errors.js'; import { createRouteCommandLedger, createRouteCommandService } from './chat-routes-test-utils.js'; function createRoutesFixture({ - unavailableProjectPaths = [], lastActivityAtByChat = {}, createdAtByChat = {}, withoutSearchIndex = false, @@ -83,15 +82,6 @@ function createRoutesFixture({ pauseChatQueue: mock(async () => ({ entries: [], pause: null, version: 2 })), resumeChatQueue: mock(async () => ({ entries: [], pause: null, version: 3 })), }; - const unavailablePaths = new Set(unavailableProjectPaths); - const pathCache = { - resolveProjectPaths: mock(async (projectPaths) => new Map( - projectPaths.map((projectPath) => [projectPath, { - available: !unavailablePaths.has(projectPath), - effectiveProjectKey: unavailablePaths.has(projectPath) ? null : projectPath, - }]), - )), - }; const metadata = { listAllChatMetadata: mock(() => new Map()), getChatMetadata: mock(() => null), @@ -177,18 +167,15 @@ function createRoutesFixture({ })), }; const chatListProjector = { - buildMany: mock(async (entries, statuses) => new Map( - entries.flatMap(([chatId, session]) => { - const status = statuses.get(session.projectPath); - return status?.available && status.effectiveProjectKey ? [[chatId, { + buildMany: mock((entries) => new Map( + entries.map(([chatId]) => [chatId, { id: chatId, activity: { createdAt: createdAtByChat[chatId] ?? null, lastActivityAt: lastActivityAtByChat[chatId] ?? null, lastReadAt: null, }, - }]] : []; - }), + }]), )), }; const commandLedger = createRouteCommandLedger('chats-search'); @@ -197,7 +184,6 @@ function createRoutesFixture({ settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -213,7 +199,7 @@ function createRoutesFixture({ }), }); - return { routes, searchIndex, registry, agents, pathCache, chatListProjector }; + return { routes, searchIndex, registry, agents, chatListProjector }; } async function postSearch(routes, body, signal) { @@ -248,34 +234,12 @@ describe('POST /api/v1/chats/search', () => { }); it('returns a quiet client-closed response for caller cancellation', async () => { - const { - routes, searchIndex, pathCache, chatListProjector, - } = createRoutesFixture(); + const { routes, searchIndex, chatListProjector } = createRoutesFixture(); const abort = new AbortController(); abort.abort(); const response = await postSearch(routes, { query: 'needle' }, abort.signal); - expect(response.status).toBe(499); - expect(await response.text()).toBe(''); - expect(pathCache.resolveProjectPaths).not.toHaveBeenCalled(); - expect(chatListProjector.buildMany).not.toHaveBeenCalled(); - expect(searchIndex.search).not.toHaveBeenCalled(); - }); - - it('returns 499 when cancellation arrives during path preparation', async () => { - const { - routes, searchIndex, pathCache, chatListProjector, - } = createRoutesFixture(); - const pendingStatuses = Promise.withResolvers(); - pathCache.resolveProjectPaths.mockReturnValueOnce(pendingStatuses.promise); - const abort = new AbortController(); - - const pendingResponse = postSearch(routes, { query: 'needle' }, abort.signal); - abort.abort(); - pendingStatuses.resolve(new Map()); - const response = await pendingResponse; - expect(response.status).toBe(499); expect(await response.text()).toBe(''); expect(chatListProjector.buildMany).not.toHaveBeenCalled(); @@ -284,7 +248,7 @@ describe('POST /api/v1/chats/search', () => { it('searches only requested chats that still exist in the registry', async () => { const { - routes, searchIndex, registry, pathCache, chatListProjector, + routes, searchIndex, registry, chatListProjector, } = createRoutesFixture(); const response = await postSearch(routes, { @@ -315,14 +279,11 @@ describe('POST /api/v1/chats/search', () => { }); expect(registry.listAllChats).not.toHaveBeenCalled(); expect(registry.getChat.mock.calls.map(([chatId]) => chatId)).toEqual(['c2', 'missing']); - expect(pathCache.resolveProjectPaths).toHaveBeenCalledWith(['/tmp/other-project']); expect(chatListProjector.buildMany.mock.calls[0][0].map(([chatId]) => chatId)).toEqual(['c2']); }); - it('excludes chats whose project paths are unavailable', async () => { - const { routes, searchIndex } = createRoutesFixture({ - unavailableProjectPaths: ['/tmp/other-project'], - }); + it('includes chats without checking project path availability', async () => { + const { routes, searchIndex } = createRoutesFixture(); const response = await postSearch(routes, { query: 'needle', @@ -331,13 +292,13 @@ describe('POST /api/v1/chats/search', () => { expect(response.status).toBe(200); expect(searchIndex.search).toHaveBeenCalledWith(expect.objectContaining({ - allowedChatIds: ['c1'], + allowedChatIds: ['c1', 'c2'], })); }); it('preserves deduplicated explicit request order for relevance search', async () => { const { - routes, searchIndex, registry, pathCache, chatListProjector, + routes, searchIndex, registry, chatListProjector, } = createRoutesFixture(); const response = await postSearch(routes, { @@ -348,10 +309,6 @@ describe('POST /api/v1/chats/search', () => { expect(response.status).toBe(200); expect(registry.listAllChats).not.toHaveBeenCalled(); expect(registry.getChat.mock.calls.map(([chatId]) => chatId)).toEqual(['c2', 'c1']); - expect(pathCache.resolveProjectPaths).toHaveBeenCalledWith([ - '/tmp/other-project', - '/tmp/project', - ]); expect(chatListProjector.buildMany.mock.calls[0][0].map(([chatId]) => chatId)) .toEqual(['c2', 'c1']); expect(searchIndex.search).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/server/routes/__tests__/chats-start.test.js b/server/routes/__tests__/chats-start.test.js index 7bd47aa11..2bd46b9cb 100644 --- a/server/routes/__tests__/chats-start.test.js +++ b/server/routes/__tests__/chats-start.test.js @@ -28,7 +28,7 @@ mock.module('../../config.js', () => ({ import createChatRoutes from '../chats.js'; import { parseJsonBody } from '../../lib/http-request.js'; -import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, createRoutePathCache } from './chat-routes-test-utils.js'; +import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService } from './chat-routes-test-utils.js'; const testChats = new Map(); const normalChatIds = []; @@ -102,7 +102,6 @@ const queue = { } }), }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -130,14 +129,13 @@ const agents = { }; const commandLedger = createRouteCommandLedger('chats-start'); -const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }); +const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents }); const routes = createChatRoutes({ registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -149,7 +147,6 @@ const routes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, chatListProjector, }), }); diff --git a/server/routes/__tests__/chats-title.test.js b/server/routes/__tests__/chats-title.test.js index 44efcba96..60d36f430 100644 --- a/server/routes/__tests__/chats-title.test.js +++ b/server/routes/__tests__/chats-title.test.js @@ -35,7 +35,7 @@ mock.module('../../chats/title-generator.js', () => ({ })); import createChatRoutes from '../chats.js'; -import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, createRoutePathCache } from './chat-routes-test-utils.js'; +import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService } from './chat-routes-test-utils.js'; const CHAT_ID = '1783725900000900'; const CHAT_ID_2 = '1783725900000901'; @@ -95,7 +95,6 @@ const queue = { abortForChatDeletion: mock(() => Promise.resolve(true)), deleteChatQueueFile: mock(() => Promise.resolve(undefined)), }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -117,7 +116,7 @@ const agents = { }; const commandLedger = createRouteCommandLedger('chats-title'); -const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }); +const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents }); const recentTitleIcons = { getRecentIcons: () => [], }; @@ -128,7 +127,6 @@ const chatsRoutes = createChatRoutes({ recentTitleIcons, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -140,7 +138,6 @@ const chatsRoutes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, chatListProjector, }), }); @@ -149,7 +146,6 @@ const allMocks = [ registry.listAllChats, metadata.listAllChatMetadata, registry.getChat, registry.removeChat, queue.abortForChatDeletion, queue.deleteChatQueueFile, settings.getChatName, settings.ensureInNormal, settings.removeSessionName, settings.removeFromAllOrderLists, settings.getNormalChatIds, - pathCache.resolveProjectPaths, parseJsonBody, generateChatTitleFromMessage, ]; @@ -171,12 +167,6 @@ describe('GET /api/chats title resolution', () => { beforeEach(() => { allMocks.forEach(m => m.mockClear()); - pathCache.resolveProjectPaths.mockImplementation((projectPaths) => Promise.resolve(new Map( - projectPaths.map((projectPath) => [projectPath, { - available: true, - effectiveProjectKey: projectPath, - }]), - ))); }); it('uses override title when session name exists', async () => { @@ -245,13 +235,7 @@ describe('GET /api/chats title resolution', () => { expect(settings.ensureInNormal).not.toHaveBeenCalled(); }); - it('checks project path availability concurrently', async () => { - let resolveSlow; - const slowCheck = new Promise((resolve) => { resolveSlow = resolve; }); - let resolveFirstCall; - const firstCall = new Promise((resolve) => { resolveFirstCall = resolve; }); - let fastCalled = false; - + it('lists chats without resolving project paths', async () => { registry.listAllChats.mockImplementation(() => ({ [CHAT_ID_5]: chatEntry({ projectPath: '/slow' }), [CHAT_ID_6]: chatEntry({ projectPath: '/fast' }), @@ -260,25 +244,7 @@ describe('GET /api/chats title resolution', () => { settings.getPinnedChatIds.mockImplementation(() => []); settings.getNormalChatIds.mockImplementation(() => [CHAT_ID_5, CHAT_ID_6]); settings.getArchivedChatIds.mockImplementation(() => []); - pathCache.resolveProjectPaths.mockImplementation(async (projectPaths) => { - const entries = await Promise.all(projectPaths.map(async (projectPath) => { - if (projectPath === '/slow') { - resolveFirstCall(); - await slowCheck; - } - if (projectPath === '/fast') fastCalled = true; - return [projectPath, { available: true, effectiveProjectKey: projectPath }]; - })); - return new Map(entries); - }); - - const responsePromise = handler(); - await firstCall; - - expect(fastCalled).toBe(true); - resolveSlow(true); - - const response = await responsePromise; + const response = await handler(); const body = await response.json(); expect(body.sessions.map((session) => session.id)).toEqual([CHAT_ID_5, CHAT_ID_6]); }); diff --git a/server/routes/__tests__/chats-validate-start.test.js b/server/routes/__tests__/chats-validate-start.test.js index 9a4e8b812..4bffc3f6c 100644 --- a/server/routes/__tests__/chats-validate-start.test.js +++ b/server/routes/__tests__/chats-validate-start.test.js @@ -11,7 +11,7 @@ mock.module('../../config.js', () => ({ })); import createChatRoutes from '../chats.js'; -import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, createRoutePathCache } from './chat-routes-test-utils.js'; +import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService } from './chat-routes-test-utils.js'; const registry = { getChat: mock(() => undefined), @@ -37,7 +37,6 @@ const settings = { })), }; const queue = { deleteChatQueueFile: mock(() => Promise.resolve(undefined)) }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -59,18 +58,17 @@ const agents = { }; const commandLedger = createRouteCommandLedger('chats-validate-start'); -const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }); +const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents }); -const routes = createChatRoutes({ +const routeDeps = { registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, - chatListProjector, + chatListProjector, commandService: createRouteCommandService({ registry, queue, @@ -78,10 +76,10 @@ const routes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, - chatListProjector, + chatListProjector, }), -}); +}; +const routes = createChatRoutes(routeDeps); const handler = routes['/api/v1/chats/validate-start'].GET; async function ensureCleanBase() { @@ -136,6 +134,26 @@ describe('GET /api/v1/chats/validate-start', () => { expect(body.errorCode).toBe('not_directory'); }); + it('returns permission_denied for inaccessible directories', async () => { + const deniedRoutes = createChatRoutes({ + ...routeDeps, + inspectProject: mock(async () => ({ + kind: 'unavailable', + reason: 'permission-denied', + })), + }); + const deniedHandler = deniedRoutes['/api/v1/chats/validate-start'].GET; + const request = new Request( + `http://localhost/api/v1/chats/validate-start?path=${encodeURIComponent(testBasePath)}`, + ); + const response = await deniedHandler(request, new URL(request.url)); + + await expect(response.json()).resolves.toMatchObject({ + valid: false, + errorCode: 'permission_denied', + }); + }); + it('returns valid true and isGitRepo false for plain directories', async () => { const dirPath = path.join(testBasePath, 'plain'); await fs.mkdir(dirPath, { recursive: true }); diff --git a/server/routes/__tests__/project-path-resolver.test.js b/server/routes/__tests__/project-path-resolver.test.js new file mode 100644 index 000000000..296e0a69e --- /dev/null +++ b/server/routes/__tests__/project-path-resolver.test.js @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveAccessibleProjectPath } from '../project-path-resolver.ts'; + +const PROJECT_PATH = '/workspace/project'; + +describe('resolveAccessibleProjectPath', () => { + it('rejects non-directory project roots with a typed response', async () => { + const result = await resolveAccessibleProjectPath( + PROJECT_PATH, + async () => ({ kind: 'unavailable', reason: 'not-a-directory' }), + ); + + expect(result.error?.status).toBe(400); + await expect(result.error?.json()).resolves.toMatchObject({ + errorCode: 'PROJECT_PATH_NOT_DIRECTORY', + retryable: false, + }); + }); + + it('preserves the permission-denied response contract', async () => { + const result = await resolveAccessibleProjectPath( + PROJECT_PATH, + async () => ({ kind: 'unavailable', reason: 'permission-denied' }), + ); + + expect(result.error?.status).toBe(403); + await expect(result.error?.json()).resolves.toMatchObject({ + error: `Project folder cannot be accessed: ${PROJECT_PATH}`, + errorCode: 'VALIDATION_FAILED', + retryable: false, + }); + }); +}); diff --git a/server/routes/__tests__/project-resolution.test.js b/server/routes/__tests__/project-resolution.test.js new file mode 100644 index 000000000..af30ca99b --- /dev/null +++ b/server/routes/__tests__/project-resolution.test.js @@ -0,0 +1,119 @@ +import { describe, expect, it, mock } from 'bun:test'; +import { createProjectResolutionRoutes } from '../project-resolution.ts'; + +const CHAT_ID = '1783725900000800'; + +function request(handler, query) { + const url = new URL(`http://localhost/api/v1/projects/resolve?${query}`); + return handler(new Request(url), url); +} + +function fixture(overrides = {}) { + const registry = { + getChat: mock(() => ({ projectPath: '/workspace/project' })), + ...overrides.registry, + }; + const inspect = mock(async () => ({ kind: 'available', effectiveProjectKey: '/real/project' })); + const routes = createProjectResolutionRoutes({ registry, inspect: overrides.inspect ?? inspect }); + return { handler: routes['/api/v1/projects/resolve'].GET, registry, inspect }; +} + +describe('GET /api/v1/projects/resolve', () => { + it('resolves chat and raw-path targets without caching', async () => { + const chat = fixture(); + const chatResponse = await request( + chat.handler, + new URLSearchParams({ chatId: CHAT_ID, expectedProjectPath: '/workspace/project' }), + ); + expect(chatResponse.status).toBe(200); + expect(chatResponse.headers.get('Cache-Control')).toBe('no-store'); + await expect(chatResponse.json()).resolves.toEqual({ + target: { kind: 'chat', chatId: CHAT_ID, projectPath: '/workspace/project' }, + resolution: { kind: 'available', effectiveProjectKey: '/real/project' }, + }); + + const raw = fixture({ + inspect: mock(async () => ({ kind: 'unavailable', reason: 'not-found' })), + }); + const rawResponse = await request( + raw.handler, + new URLSearchParams({ projectPath: '/workspace/missing' }), + ); + expect(rawResponse.status).toBe(200); + await expect(rawResponse.json()).resolves.toMatchObject({ + target: { kind: 'path', projectPath: '/workspace/missing' }, + resolution: { kind: 'unavailable', reason: 'not-found' }, + }); + }); + + it('rejects missing chats and stale bindings before inspection', async () => { + const missing = fixture({ registry: { getChat: mock(() => null) } }); + const missingResponse = await request( + missing.handler, + new URLSearchParams({ chatId: CHAT_ID, expectedProjectPath: '/workspace/project' }), + ); + expect(missingResponse.status).toBe(404); + await expect(missingResponse.json()).resolves.toMatchObject({ errorCode: 'SESSION_NOT_FOUND' }); + + const stale = fixture(); + const staleResponse = await request( + stale.handler, + new URLSearchParams({ chatId: CHAT_ID, expectedProjectPath: '/workspace/old' }), + ); + expect(staleResponse.status).toBe(409); + await expect(staleResponse.json()).resolves.toMatchObject({ errorCode: 'PROJECT_PATH_CHANGED' }); + expect(stale.inspect).not.toHaveBeenCalled(); + }); + + it('rejects a binding changed during inspection', async () => { + let reads = 0; + const current = fixture({ + registry: { + getChat: mock(() => ({ + projectPath: reads++ === 0 ? '/workspace/project' : '/workspace/new', + })), + }, + }); + const response = await request( + current.handler, + new URLSearchParams({ chatId: CHAT_ID, expectedProjectPath: '/workspace/project' }), + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ errorCode: 'PROJECT_PATH_CHANGED' }); + }); + + it('accepts only the two exact query forms', async () => { + const { handler, inspect } = fixture(); + for (const query of [ + '', + 'chatId=1783725900000800', + 'projectPath=%2Fworkspace%2Fproject&extra=true', + 'projectPath=%2Fa&projectPath=%2Fb', + 'chatId=1783725900000800&expectedProjectPath=%2Fa&projectPath=%2Fa', + ]) { + const response = await request(handler, query); + expect(response.status).toBe(400); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + } + expect(inspect).not.toHaveBeenCalled(); + }); + + it('returns unexpected inspection failures without caching them', async () => { + const failure = fixture({ + inspect: mock(async () => { throw new Error('device failed'); }), + }); + const response = await request( + failure.handler, + new URLSearchParams({ projectPath: '/workspace/project' }), + ); + + expect(response.status).toBe(500); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toMatchObject({ + error: 'Internal server error', + errorCode: 'INTERNAL_ERROR', + retryable: true, + }); + }); +}); diff --git a/server/routes/__tests__/tag-normalization.test.js b/server/routes/__tests__/tag-normalization.test.js index 2714d159a..1e8bf184b 100644 --- a/server/routes/__tests__/tag-normalization.test.js +++ b/server/routes/__tests__/tag-normalization.test.js @@ -16,7 +16,7 @@ mock.module('../../chats/title-generator.js', () => ({ })); import createChatRoutes from '../chats.js'; -import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService, createRoutePathCache } from './chat-routes-test-utils.js'; +import { createRouteChatListProjector, createRouteCommandLedger, createRouteCommandService } from './chat-routes-test-utils.js'; import { parseJsonBody } from '../../lib/http-request.js'; const registry = { @@ -46,7 +46,6 @@ const settings = { recordChatStartup: mock(() => Promise.resolve(undefined)), }; const queue = { deleteChatQueueFile: mock(() => Promise.resolve(undefined)) }; -const pathCache = createRoutePathCache(); const metadata = { addNewChatMetadata: mock(() => undefined), listAllChatMetadata: mock(() => new Map()), @@ -69,14 +68,13 @@ const agents = { }; const commandLedger = createRouteCommandLedger('tag-normalization'); -const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents, pathCache }); +const chatListProjector = createRouteChatListProjector({ registry, settings, metadata, agents }); const chatsRoutes = createChatRoutes({ registry, settings, queue, processing: { phase: mock(() => null) }, - pathCache, metadata, chatViews, agents, @@ -88,7 +86,6 @@ const chatsRoutes = createChatRoutes({ metadata, agents, commandLedger, - pathCache, chatListProjector, }), }); diff --git a/server/routes/chat-search-routes.ts b/server/routes/chat-search-routes.ts index 1899eabca..f34a3a0ad 100644 --- a/server/routes/chat-search-routes.ts +++ b/server/routes/chat-search-routes.ts @@ -63,11 +63,6 @@ export interface ChatSearchDep { interface ChatSearchRouteDeps { registry: IChatRegistry; - pathCache: { - resolveProjectPaths(projectPaths: string[]): Promise< - Map - >; - }; chatListProjector: ChatListProjector; searchIndex?: ChatSearchDep; } @@ -86,7 +81,7 @@ export function createChatSearchRoutes(deps: ChatSearchRouteDeps): { postSearchNavigate(body: unknown): Promise; getSearchStatus(): Response; } { - const { registry, pathCache, chatListProjector, searchIndex } = deps; + const { registry, chatListProjector, searchIndex } = deps; async function postSearchChats(body: unknown, request?: Request): Promise { try { @@ -101,7 +96,6 @@ export function createChatSearchRoutes(deps: ChatSearchRouteDeps): { request?.signal.throwIfAborted(); const allowedChatIds = await searchableChatIds( registry, - pathCache, chatListProjector, search.chatIds, search.sort, @@ -266,7 +260,6 @@ function parseSearchRequest(body: unknown): NormalizedChatSearchRequest { async function searchableChatIds( registry: IChatRegistry, - pathCache: ChatSearchRouteDeps['pathCache'], chatListProjector: ChatListProjector, requestedChatIds: string[] | undefined, sort: ChatSearchSort, @@ -279,11 +272,7 @@ async function searchableChatIds( return session ? [[chatId, session] as const] : []; }); signal?.throwIfAborted(); - const statuses = await pathCache.resolveProjectPaths( - sessionEntries.map(([, session]) => session.projectPath), - ); - signal?.throwIfAborted(); - const visibleEntries = await chatListProjector.buildMany(sessionEntries, statuses); + const visibleEntries = chatListProjector.buildMany(sessionEntries); const entries = [...visibleEntries.values()]; if (sort === 'relevance') return entries.map((entry) => entry.id); const timestamps = (entry: ChatListEntry): ChatOrderTimestamps => ({ diff --git a/server/routes/chats.ts b/server/routes/chats.ts index 95efa5565..be9db2629 100644 --- a/server/routes/chats.ts +++ b/server/routes/chats.ts @@ -1,6 +1,5 @@ // /api/chats/* route handlers for registry operations and ledger-backed transcripts. -import { promises as fs } from 'fs'; import { withJsonBody } from '../lib/json-route.js'; import type { IChatRegistry } from '../chats/store.js'; import { @@ -39,7 +38,6 @@ import type { SetLastSelectedChatResponse, } from '../../common/chat-list.js'; import { CHAT_MESSAGES_MAX_LIMIT } from '../lib/pagination.js'; -import { assertRealWithinProjectBase, isProjectBoundaryError } from '../lib/path-boundary.js'; import { jsonError, jsonErrorFromUnknown } from '../lib/http-error.js'; import { GoalControlDeliveryError, @@ -69,6 +67,7 @@ import { buildChatOrderComparator } from '../chats/chat-order-ranking.js'; import type { AgentRegistryServiceContract } from '../agents/registry.js'; import { createLogger } from '../lib/log.js'; import { readOnlyGitOptions, runGit } from '../git/run.js'; +import { inspectProjectDirectory } from '../projects/project-directory-service.js'; import type { CompleteChatHistoryResponse, TranscriptReadPurpose, @@ -169,12 +168,6 @@ interface SettingsDep { ): Promise<{ changed: boolean }>; } -interface PathCacheDep { - resolveProjectPaths( - projectPaths: readonly string[], - ): Promise>; -} - interface MetadataDep { listAllChatMetadata(): Map; getChatMetadata(chatId: string): ChatMetadata | null; @@ -300,7 +293,6 @@ interface ChatRouteDeps { recentTitleIcons: RecentTitleIconSource; queue: QueueDep; processing: Pick; - pathCache: PathCacheDep; metadata: MetadataDep; chatViews: ChatViewsDep; agents: AgentRegistryDep; @@ -308,6 +300,7 @@ interface ChatRouteDeps { chatListProjector: import('../chats/chat-list-projector.js').ChatListProjector; searchIndex?: ChatSearchDep; lastSelectedChat?: LastSelectedChatState; + inspectProject?: typeof inspectProjectDirectory; } export default function createChatRoutes({ @@ -316,7 +309,6 @@ export default function createChatRoutes({ recentTitleIcons, queue, processing, - pathCache, metadata, chatViews, agents, @@ -324,11 +316,11 @@ export default function createChatRoutes({ chatListProjector, searchIndex, lastSelectedChat = new InMemoryLastSelectedChatState(), + inspectProject = inspectProjectDirectory, }: ChatRouteDeps): RouteMap { const commands = commandService; const searchRoutes = createChatSearchRoutes({ registry, - pathCache, chatListProjector, searchIndex, }); @@ -336,14 +328,13 @@ export default function createChatRoutes({ function validatedLastSelectedChatId( rememberedChatId: string | null, allSessions: Record, - visibleEntries: Map, ): string | null { if (!rememberedChatId) return null; if (!(rememberedChatId in allSessions)) { lastSelectedChat.clearIf(rememberedChatId); return null; } - return visibleEntries.has(rememberedChatId) ? rememberedChatId : null; + return rememberedChatId; } async function validateStartPath(_request: Request, url: URL): Promise { @@ -353,24 +344,25 @@ export default function createChatRoutes({ } try { - const projectPath = await assertRealWithinProjectBase(dirPath); - const stat = await fs.stat(projectPath); - if (!stat.isDirectory()) { - return pathValidationError('Not a directory', 'not_directory'); + const resolution = await inspectProject(dirPath); + if (resolution.kind === 'unavailable') { + switch (resolution.reason) { + case 'not-found': + return pathValidationError('Path does not exist', 'path_not_found'); + case 'not-a-directory': + return pathValidationError('Not a directory', 'not_directory'); + case 'outside-base': + return pathValidationError( + 'Path is outside the allowed base directory', + 'outside_base_dir', + ); + case 'permission-denied': + return pathValidationError('Permission denied', 'permission_denied'); + } } - const isGitRepo = await isGitRepository(projectPath); + const isGitRepo = await isGitRepository(resolution.effectiveProjectKey); return Response.json({ valid: true, isGitRepo }); } catch (error: unknown) { - if (isProjectBoundaryError(error)) { - return pathValidationError('Path is outside the allowed base directory', 'outside_base_dir'); - } - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { - return pathValidationError('Path does not exist', 'path_not_found'); - } - if (err.code === 'EACCES' || err.code === 'EPERM') { - return pathValidationError('Permission denied', 'permission_denied'); - } return pathValidationError((error as Error).message, 'unknown'); } } @@ -382,8 +374,7 @@ export default function createChatRoutes({ const normalList = settings.getNormalChatIds(); const archivedList = settings.getArchivedChatIds(); const sessionEntries = Object.entries(sessions); - const statuses = await pathCache.resolveProjectPaths(sessionEntries.map(([, session]) => session.projectPath)); - const entryMap = await chatListProjector.buildMany(sessionEntries, statuses); + const entryMap = chatListProjector.buildMany(sessionEntries); const orderedFrom = (ids: string[], group: ChatOrderGroup): ChatListEntry[] => ids.flatMap((id) => { const entry = entryMap.get(id); @@ -403,7 +394,6 @@ export default function createChatRoutes({ const lastSelectedChatId = validatedLastSelectedChatId( lastSelectedChat.getLastSelectedChatId(), sessions, - entryMap, ); const body: ChatListResponse = { sessions: all, diff --git a/server/routes/index.ts b/server/routes/index.ts index f3ca1d5fc..5facaa72b 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -23,12 +23,12 @@ import { createChatRowRoutes } from './chat-rows.js'; import { createChatExportRoutes } from './chat-export.js'; import { createChatHandoffArtifactRoutes } from './chat-handoff-artifact.js'; import { createNativeSessionLookupRoutes } from './native-session-lookup.js'; +import { createProjectResolutionRoutes } from './project-resolution.js'; import type { ServerRuntimeState } from '../lib/server-runtime.js'; import type { RouteMap } from '../lib/http-route-types.js'; import type { IChatRegistry } from '../chats/store.js'; import type { SettingsStore } from '../settings/store.js'; import type { ChatExecutionService } from '../chat-execution/chat-execution-coordinator.js'; -import type { PathCache } from '../chats/path-cache.js'; import type { MetadataIndex } from '../chats/metadata-store.js'; import type { TranscriptPageReader } from '../chats/chat-message-reader.js'; import type { ShareTranscriptSnapshotPort } from './shares.js'; @@ -62,7 +62,6 @@ export default function createAllRoutes({ recentTitleIcons, queue, processing, - pathCache, metadata, chatViews, shareSnapshots, @@ -94,7 +93,6 @@ export default function createAllRoutes({ recentTitleIcons: RecentTitleIconSource; queue: ChatExecutionService; processing: ChatProcessingActivity; - pathCache: PathCache; metadata: MetadataIndex; chatViews: TranscriptPageReader; shareSnapshots: ShareTranscriptSnapshotPort; @@ -134,6 +132,7 @@ export default function createAllRoutes({ ...createChatExportRoutes(transcriptExport), ...createChatHandoffArtifactRoutes(handoffArtifact), ...createNativeSessionLookupRoutes(registry, agents), + ...createProjectResolutionRoutes({ registry }), ...createStaticRoutes(settings), ...authRoutes, ...createAgentRoutes({ agents, apiProviders }), @@ -144,7 +143,6 @@ export default function createAllRoutes({ recentTitleIcons, queue, processing, - pathCache, metadata, chatViews, agents, diff --git a/server/routes/project-path-resolver.ts b/server/routes/project-path-resolver.ts index 628963e62..298135a62 100644 --- a/server/routes/project-path-resolver.ts +++ b/server/routes/project-path-resolver.ts @@ -2,14 +2,11 @@ // param, enforcing the project-base boundary. Shared by routes that operate // against a project directory (files, slash-command discovery). -import { promises as fs } from 'fs'; -import { hasNodeErrorCode } from '../lib/errors.js'; -import { - assertRealWithinProjectBase, - isProjectBoundaryError, - projectBoundaryErrorResponse, -} from '../lib/path-boundary.ts'; +import type { ProjectUnavailableReason } from '../../common/project-resolution.js'; +import { jsonError } from '../lib/http-error.js'; +import { projectBoundaryErrorResponse } from '../lib/path-boundary.ts'; import type { IChatRegistry } from '../chats/store.js'; +import { inspectProjectDirectory } from '../projects/project-directory-service.js'; export type ProjectPathResolution = | { projectPath: string; error?: undefined } @@ -17,24 +14,12 @@ export type ProjectPathResolution = export async function resolveAccessibleProjectPath( projectPath: string, + inspect = inspectProjectDirectory, ): Promise { - let resolvedProjectPath = projectPath; - try { - resolvedProjectPath = await assertRealWithinProjectBase(projectPath); - } catch (error) { - if (isProjectBoundaryError(error)) return { error: projectBoundaryErrorResponse() }; - if (hasNodeErrorCode(error, 'ENOENT') || hasNodeErrorCode(error, 'ENOTDIR')) { - return { error: projectPathNotFoundResponse(resolvedProjectPath) }; - } - throw error; - } - - try { - await fs.access(resolvedProjectPath); - return { projectPath: resolvedProjectPath }; - } catch { - return { error: projectPathNotFoundResponse(resolvedProjectPath) }; - } + const resolution = await inspect(projectPath); + return resolution.kind === 'available' + ? { projectPath: resolution.effectiveProjectKey } + : { error: unavailableResponse(projectPath, resolution.reason) }; } function projectPathNotFoundResponse(projectPath: string): Response { @@ -44,6 +29,25 @@ function projectPathNotFoundResponse(projectPath: string): Response { ); } +function unavailableResponse(projectPath: string, reason: ProjectUnavailableReason): Response { + if (reason === 'not-found') return projectPathNotFoundResponse(projectPath); + if (reason === 'outside-base') return projectBoundaryErrorResponse(); + if (reason === 'not-a-directory') { + return jsonError( + `Project path is not a directory: ${projectPath}`, + 400, + 'PROJECT_PATH_NOT_DIRECTORY', + false, + ); + } + return jsonError( + `Project folder cannot be accessed: ${projectPath}`, + 403, + 'VALIDATION_FAILED', + false, + ); +} + // Resolves the project path from either a chatId or projectPath query param. export async function resolveProjectPathFromUrl( registry: IChatRegistry, diff --git a/server/routes/project-resolution.ts b/server/routes/project-resolution.ts new file mode 100644 index 000000000..8f0f486ed --- /dev/null +++ b/server/routes/project-resolution.ts @@ -0,0 +1,83 @@ +import { + type ProjectResolutionResponse, + type ProjectTarget, +} from '../../common/project-resolution.js'; +import { parseChatId } from '../../common/chat-id.js'; +import type { IChatRegistry } from '../chats/store.js'; +import { DomainError, ValidationDomainError } from '../lib/domain-error.js'; +import { jsonErrorFromUnknown } from '../lib/http-error.js'; +import type { RouteMap } from '../lib/http-route-types.js'; +import { inspectProjectDirectory } from '../projects/project-directory-service.js'; + +interface ProjectResolutionRouteDeps { + registry: Pick; + inspect?: typeof inspectProjectDirectory; +} + +export function createProjectResolutionRoutes( + deps: ProjectResolutionRouteDeps, +): RouteMap { + const inspect = deps.inspect ?? inspectProjectDirectory; + return { + '/api/v1/projects/resolve': { + GET: async (_request, url) => { + try { + const target = parseTarget(url); + assertCurrentBinding(deps.registry, target); + const resolution = await inspect(target.projectPath); + assertCurrentBinding(deps.registry, target); + return noStore(Response.json({ target, resolution } satisfies ProjectResolutionResponse)); + } catch (error) { + return noStore(jsonErrorFromUnknown(error)); + } + }, + }, + }; +} + +function parseTarget(url: URL): ProjectTarget { + const entries = [...url.searchParams.entries()]; + const chatId = url.searchParams.get('chatId') ?? ''; + const expectedProjectPath = url.searchParams.get('expectedProjectPath') ?? ''; + const projectPath = url.searchParams.get('projectPath') ?? ''; + if ( + entries.length === 2 + && url.searchParams.getAll('chatId').length === 1 + && url.searchParams.getAll('expectedProjectPath').length === 1 + && chatId + && expectedProjectPath.trim() + ) { + try { + return { kind: 'chat', chatId: parseChatId(chatId), projectPath: expectedProjectPath }; + } catch { + throw new ValidationDomainError('chatId must be a canonical Garcon chat ID'); + } + } + if ( + entries.length === 1 + && url.searchParams.getAll('projectPath').length === 1 + && projectPath.trim() + ) { + return { kind: 'path', projectPath }; + } + throw new ValidationDomainError( + 'Provide either chatId with expectedProjectPath, or projectPath', + ); +} + +function assertCurrentBinding( + registry: Pick, + target: ProjectTarget, +): void { + if (target.kind !== 'chat') return; + const chat = registry.getChat(target.chatId); + if (!chat) throw new DomainError('SESSION_NOT_FOUND', 'Session not found', 404); + if (chat.projectPath !== target.projectPath) { + throw new DomainError('PROJECT_PATH_CHANGED', 'The chat project changed', 409); + } +} + +function noStore(response: Response): Response { + response.headers.set('Cache-Control', 'no-store'); + return response; +} diff --git a/server/server-event-wiring.ts b/server/server-event-wiring.ts index 33520ba4f..a5eea7db5 100644 --- a/server/server-event-wiring.ts +++ b/server/server-event-wiring.ts @@ -580,7 +580,6 @@ export function wireServerEvents({ payload.projectPath, payload.effectiveProjectKey, payload.previousProjectPath, - payload.previousEffectiveProjectKey, ), ); }); @@ -618,6 +617,11 @@ export function wireServerEvents({ scheduleChatTask(chatId, 'server-events: queued turn failure handling failed', () => handleQueueFailure(chatId, queueErrorMessage, options)); }); + queue.onProjectUnavailable((chatId, error) => { + scheduleChatTask(chatId, 'server-events: project unavailable notice failed', () => { + notifyOperationalNotice(chatId, 'warning', error.message); + }); + }); queue.onTurnSettled((chatId, turn) => { if (turn) agentRegistry.settleTurn(chatId, turn); }); diff --git a/server/server.ts b/server/server.ts index 2f731a60d..efd4d8263 100644 --- a/server/server.ts +++ b/server/server.ts @@ -32,7 +32,6 @@ import { } from './chat-execution/chat-execution-coordinator.js'; import { InMemoryChatExecutionControlRepository } from './chat-execution/chat-execution-control-repository.js'; import { queueDrainOptions } from './chats/chat-execution-options.js'; -import { PathCache } from './chats/path-cache.js'; import { TerminalManager } from './terminals/terminal-manager.js'; import { TerminalStreamHandler } from './ws/terminal-stream.js'; import { PrimaryWsHandler } from './ws/primary.js'; @@ -88,6 +87,7 @@ import { ScheduledPromptRunLog } from './scheduled-prompts/run-log.js'; import { ScheduledPromptDispatcher } from './scheduled-prompts/dispatcher.js'; import { ScheduledPromptScheduler } from './scheduled-prompts/scheduler.js'; import { ChatListProjector } from './chats/chat-list-projector.js'; +import { ProjectAdmission } from './projects/project-admission.js'; import { AgentOwnershipJournal } from './chats/agent-ownership-journal.js'; import { CarryOverGarbageCollector } from './chats/carryover-garbage-collector.js'; import { CarryOverTranscriptStore } from './chats/carryover-transcript-store.js'; @@ -229,7 +229,6 @@ export async function startServer(): Promise { logger.warn('chat-title: failed to record recent icons:', errorMessage(error)); } }); - const pathCache = new PathCache(); const terminalManager = new TerminalManager(); const terminalStream = new TerminalStreamHandler(terminalManager); const wsAdmission = new WebSocketAdmissionController(config.maxWsClients); @@ -526,6 +525,7 @@ export async function startServer(): Promise { await shareStore.init(); const commandLedger = new CommandLedger(workspaceDir); + const projectAdmission = new ProjectAdmission(chatRegistry); queue = new ChatExecutionCoordinator( workspaceDir, agentRegistry, @@ -533,9 +533,12 @@ export async function startServer(): Promise { (chatId) => queueDrainOptions(chatId, chatRegistry), (chatId) => chatRegistry.hasChat(chatId), new InMemoryChatExecutionControlRepository(runtimeState.identity.instanceId), - (chatId) => commandLedger.unsettledQueueReceiptKeys(chatId), - agentCommands.appendControlReceipt, - selectionAdmissionLock, + { + projectAdmission, + unsettledQueueReceiptKeys: (chatId) => commandLedger.unsettledQueueReceiptKeys(chatId), + appendControlReceipt: agentCommands.appendControlReceipt, + selectionAdmissionLock, + }, ); executionQueries = queue; const transcriptReload = new TranscriptReloadService({ @@ -567,7 +570,6 @@ export async function startServer(): Promise { settings, metadata, processing: chatProcessingActivity, - pathCache, canReloadFromNativeHistory(_chatId, session) { return Boolean( session.agentSessionId @@ -600,7 +602,6 @@ export async function startServer(): Promise { }), transcripts: transcriptLedger, chatListProjector, - pathCache, ownership: agentOwnership, handoffs, transientFeeds, @@ -709,7 +710,6 @@ export async function startServer(): Promise { recentTitleIcons, queue, processing: chatProcessingActivity, - pathCache, metadata, chatViews: chatViewPages, shareSnapshots: transcriptReader, diff --git a/web/messages/en.json b/web/messages/en.json index 0d72c2271..f972b0c2b 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -910,6 +910,13 @@ "workspace_back": "Back", "workspace_loading_terminal": "Loading terminal...", "workspace_resolving_project": "Resolving project...", + "workspace_project_unavailable": "Project folder unavailable", + "workspace_project_not_found": "The folder could not be found.", + "workspace_project_not_directory": "The project path is not a folder.", + "workspace_project_outside_base": "The folder is outside the allowed project directory.", + "workspace_project_permission_denied": "Garcon cannot access this folder.", + "workspace_project_changed": "The chat project changed.", + "workspace_choose_project_folder": "Choose folder", "workspace_file_session_unavailable": "File session unavailable.", "workspace_pull_requests_unavailable": "Pull requests are unavailable for this workspace.", "workspace_pull_requests_checking": "Checking pull request availability...", @@ -1784,7 +1791,8 @@ "sidebar_details_unavailable": "Unavailable", "sidebar_project_path_browse": "Browse folders", "sidebar_project_path_current_label": "Current path", - "sidebar_project_path_errors_chat_not_idle": "Wait for the active turn, queued messages, or pending input to finish before changing the path.", + "sidebar_project_path_queue_warning": "Changing the project folder clears queued prompts.", + "sidebar_project_path_errors_chat_not_idle": "Wait for the active turn or pending preparation to finish before changing the path.", "sidebar_project_path_errors_destination_rejected": "The chat stayed at its current path because the destination belongs to a different agent project.", "sidebar_project_path_errors_native_path_unresolved": "The native transcript path could not be preserved for this chat.", "sidebar_project_path_errors_outcome_unknown": "The agent did not confirm the path change. Retry the same destination in a moment before continuing this chat.", diff --git a/web/src/lib/api/__tests__/chats-contract.test.ts b/web/src/lib/api/__tests__/chats-contract.test.ts index 7d802e59e..aa38d3d36 100644 --- a/web/src/lib/api/__tests__/chats-contract.test.ts +++ b/web/src/lib/api/__tests__/chats-contract.test.ts @@ -89,7 +89,6 @@ describe('chats API contract', () => { agentSettings: CLAUDE_SETTINGS, title: 'Chat', projectPath: '/project', - effectiveProjectKey: '/project', orderGroup: 'normal', tags: [], activity: { createdAt: null, lastActivityAt: null, lastReadAt: null }, @@ -161,7 +160,6 @@ describe('chats API contract', () => { agentSettings: CLAUDE_SETTINGS, title: 'Chat 1', projectPath: '/repo', - effectiveProjectKey: '/repo', orderGroup: 'normal', tags: [], activity: { createdAt: null, lastActivityAt: null, lastReadAt: null }, diff --git a/web/src/lib/api/__tests__/project-resolution.test.ts b/web/src/lib/api/__tests__/project-resolution.test.ts new file mode 100644 index 000000000..5430b3f1a --- /dev/null +++ b/web/src/lib/api/__tests__/project-resolution.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearAuthToken, setAuthToken } from '../client'; +import { resolveProject } from '../project-resolution'; + +const CHAT_ID = '1783725900000800'; + +describe('project resolution API', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + setAuthToken('test-token'); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + clearAuthToken(); + vi.unstubAllGlobals(); + }); + + it('encodes a fenced chat target and parses its response', async () => { + const target = { kind: 'chat', chatId: CHAT_ID, projectPath: '/workspace/project a' } as const; + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/real/project-a' }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect(resolveProject(target, new AbortController().signal)).resolves.toEqual({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/real/project-a' }, + }); + expect(fetchMock.mock.calls[0]?.[0]).toContain( + `chatId=${CHAT_ID}&expectedProjectPath=%2Fworkspace%2Fproject+a`, + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), + }), + ); + }); + + it('rejects a valid response for a different target', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + target: { kind: 'path', projectPath: '/workspace/other' }, + resolution: { kind: 'unavailable', reason: 'not-found' }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + resolveProject( + { kind: 'path', projectPath: '/workspace/project' }, + new AbortController().signal, + ), + ).rejects.toThrow('Invalid project resolution response'); + }); +}); diff --git a/web/src/lib/api/__tests__/ws-message-contract.logic.test.ts b/web/src/lib/api/__tests__/ws-message-contract.logic.test.ts index 6a8685643..330d0992f 100644 --- a/web/src/lib/api/__tests__/ws-message-contract.logic.test.ts +++ b/web/src/lib/api/__tests__/ws-message-contract.logic.test.ts @@ -472,7 +472,6 @@ describe('parseServerWsMessage', () => { projectPath: '/workspace/worktree', effectiveProjectKey: '/workspace/worktree', previousProjectPath: '/workspace/repo', - previousEffectiveProjectKey: '/workspace/repo', }); expect(projectPathUpdated).toBeInstanceOf(ChatProjectPathUpdatedMessage); expect((projectPathUpdated as ChatProjectPathUpdatedMessage).projectPath).toBe( diff --git a/web/src/lib/api/project-resolution.ts b/web/src/lib/api/project-resolution.ts new file mode 100644 index 000000000..9c778f03c --- /dev/null +++ b/web/src/lib/api/project-resolution.ts @@ -0,0 +1,25 @@ +import { + parseProjectResolutionResponse, + projectTargetKey, + type ProjectResolutionResponse, + type ProjectTarget, +} from '$shared/project-resolution'; +import { apiFetch, parseApiResponse } from './client.js'; + +export async function resolveProject( + target: ProjectTarget, + signal: AbortSignal, +): Promise { + const query = target.kind === 'chat' + ? new URLSearchParams({ + chatId: target.chatId, + expectedProjectPath: target.projectPath, + }) + : new URLSearchParams({ projectPath: target.projectPath }); + const response = await apiFetch(`/api/v1/projects/resolve?${query}`, { signal }); + const parsed = parseProjectResolutionResponse(await parseApiResponse(response)); + if (!parsed || projectTargetKey(parsed.target) !== projectTargetKey(target)) { + throw new Error('Invalid project resolution response'); + } + return parsed; +} diff --git a/web/src/lib/chat-map/__tests__/chat-map-model.logic.test.ts b/web/src/lib/chat-map/__tests__/chat-map-model.logic.test.ts index 10c13d8bc..409be00d3 100644 --- a/web/src/lib/chat-map/__tests__/chat-map-model.logic.test.ts +++ b/web/src/lib/chat-map/__tests__/chat-map-model.logic.test.ts @@ -14,8 +14,6 @@ function chat(id: string, overrides: Partial = {}): ChatSessi id, parentChat: null, projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: `Chat ${id}`, agentId: 'claude', diff --git a/web/src/lib/chat/conversation/__tests__/conversation-agent-switch-service.test.ts b/web/src/lib/chat/conversation/__tests__/conversation-agent-switch-service.test.ts index 06ca5a531..576723fd1 100644 --- a/web/src/lib/chat/conversation/__tests__/conversation-agent-switch-service.test.ts +++ b/web/src/lib/chat/conversation/__tests__/conversation-agent-switch-service.test.ts @@ -11,8 +11,6 @@ function createChat(overrides: Partial = {}): ChatSessionReco return { id: 'chat-1', projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', diff --git a/web/src/lib/chat/conversation/__tests__/conversation-queue-controller.test.ts b/web/src/lib/chat/conversation/__tests__/conversation-queue-controller.test.ts index 4e1f470b3..c2587ff00 100644 --- a/web/src/lib/chat/conversation/__tests__/conversation-queue-controller.test.ts +++ b/web/src/lib/chat/conversation/__tests__/conversation-queue-controller.test.ts @@ -35,8 +35,6 @@ function chatRecord(): ChatSessionRecord { id: 'chat-1', parentChat: null, projectPath: '/repo', - effectiveProjectKey: '/repo', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat 1', agentId: 'claude', diff --git a/web/src/lib/chat/conversation/__tests__/conversation-router-adapter.test.ts b/web/src/lib/chat/conversation/__tests__/conversation-router-adapter.test.ts index d955f11fb..68137ac80 100644 --- a/web/src/lib/chat/conversation/__tests__/conversation-router-adapter.test.ts +++ b/web/src/lib/chat/conversation/__tests__/conversation-router-adapter.test.ts @@ -22,8 +22,6 @@ function chatRecord(overrides: Partial = {}): ChatSessionReco return { id: 'chat-1', projectPath: '/repo', - effectiveProjectKey: '/repo', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat 1', agentId: 'claude', diff --git a/web/src/lib/chat/conversation/__tests__/conversation-session-controller.test.ts b/web/src/lib/chat/conversation/__tests__/conversation-session-controller.test.ts index 4678c5424..19cfc0789 100644 --- a/web/src/lib/chat/conversation/__tests__/conversation-session-controller.test.ts +++ b/web/src/lib/chat/conversation/__tests__/conversation-session-controller.test.ts @@ -154,8 +154,6 @@ function createRunningChat(overrides: Partial = {}): ChatSess return { id: 'chat-1', projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Unread chat', agentId: 'claude', @@ -700,6 +698,7 @@ function createDeps(chat = createRunningChat()) { setIsViewportPinnedToBottom: vi.fn(), setInitialBottomRestorePending: vi.fn(), scrollToBottom: vi.fn(), + onProjectUnavailable: vi.fn(), } satisfies SessionControllerDeps & { ws: { sendMessage: ReturnType; @@ -1579,10 +1578,7 @@ describe('ConversationSessionController', () => { await controller.submitForChat('123'); - expect(deps.chatState.appendLocalNotice).toHaveBeenCalledWith( - 'progress', - 'Forking chat...', - ); + expect(deps.chatState.appendLocalNotice).toHaveBeenCalledWith('progress', 'Forking chat...'); expect(deps.chatState.clearLocalNoticesForChat).toHaveBeenCalledWith('123', 1); expect(deps.chatState.localNotices).toEqual([]); expect(deps.chatState.isUserScrolledUp).toBe(false); @@ -1621,10 +1617,7 @@ describe('ConversationSessionController', () => { sourceChatId: '123', chatId: expect.stringMatching(/^\d+$/), }); - expect(deps.chatState.appendLocalNotice).toHaveBeenCalledWith( - 'progress', - 'Forking chat...', - ); + expect(deps.chatState.appendLocalNotice).toHaveBeenCalledWith('progress', 'Forking chat...'); expect(deps.chatState.clearLocalNoticesForChat).toHaveBeenCalledWith('123', 1); expect(deps.chatState.localNotices).toEqual([]); expect(deps.sessions.upsertServerChat).toHaveBeenCalledWith(createServerEntry('456')); @@ -2215,8 +2208,6 @@ describe('ConversationSessionController', () => { const draft = createRunningChat({ id: 'draft-1', status: 'draft', - projectIdentityState: 'pending', - effectiveProjectKey: null, model: 'opus', }); const { deps } = createDeps(draft); @@ -2244,6 +2235,30 @@ describe('ConversationSessionController', () => { ); }); + it('refreshes a draft path target after an unavailable rejection', async () => { + const draft = createRunningChat({ + id: 'draft-1', + status: 'draft', + projectPath: '/workspace/draft-project', + }); + const { deps } = createDeps(draft); + deps.sessions.isDraft = vi.fn(() => true); + deps.sessions.startupByChatId = { 'draft-1': createDraftStartup() }; + deps.composerState.inputText = 'start this project'; + mockStartChat.mockRejectedValueOnce( + new ApiError(409, 'Project folder unavailable', 'PROJECT_UNAVAILABLE'), + ); + + await expect( + new ConversationSessionController(deps).submitForChat('draft-1'), + ).resolves.toBe('rejected'); + + expect(deps.onProjectUnavailable).toHaveBeenCalledWith({ + kind: 'path', + projectPath: '/workspace/draft-project', + }); + }); + it('marks draft startup as submitting before attachment reads complete', async () => { const draft = createRunningChat({ id: 'draft-1', @@ -2557,6 +2572,38 @@ describe('ConversationSessionController', () => { expect(deps.sessions.applyProcessingEvent).not.toHaveBeenCalledWith('chat-1', false); }); + it('settles an unavailable rejection before refreshing the captured project target', async () => { + const pending = deferred>>(); + const refresh = deferred(); + mockRunChat.mockReturnValueOnce(pending.promise); + const { deps } = createDeps(); + deps.onProjectUnavailable.mockReturnValueOnce(refresh.promise); + deps.agentState.model = 'opus'; + deps.composerState.inputText = 'check this project'; + const submission = new ConversationSessionController(deps).submitForChat('chat-1'); + + deps.sessions.byId['chat-1'] = { + ...deps.sessions.byId['chat-1'], + projectPath: '/workspace/replacement', + }; + pending.reject(new ApiError(409, 'Project folder unavailable', 'PROJECT_UNAVAILABLE')); + await expect(submission).resolves.toBe('rejected'); + + expect(deps.onProjectUnavailable).toHaveBeenCalledOnce(); + expect(deps.onProjectUnavailable).toHaveBeenCalledWith({ + kind: 'chat', + chatId: 'chat-1', + projectPath: '/workspace/project', + }); + expect(deps.composerState.inputText).toBe('check this project'); + expect(deps.chatState.localNotices[0]).toMatchObject({ + noticeType: 'error', + content: 'Failed to send message: Project folder unavailable', + }); + expect(deps.composerState.isSubmitting).toBe(false); + refresh.resolve(); + }); + it('retries an ambiguous direct response once with the same identity', async () => { mockRunChat.mockRejectedValueOnce(new TypeError('connection closed')).mockResolvedValueOnce({ success: true, @@ -2694,8 +2741,7 @@ describe('ConversationSessionController', () => { lastOrdinal: 10, }); deps.chatState.getCursorForChat.mockImplementation((chatId) => ({ - transcriptViewId: - chatId === 'chat-2' ? 'background-generation' : 'foreground-generation', + transcriptViewId: chatId === 'chat-2' ? 'background-generation' : 'foreground-generation', lastOrdinal: chatId === 'chat-2' ? 20 : 10, })); mockCreateQueuedInput.mockResolvedValueOnce({ @@ -2709,10 +2755,7 @@ describe('ConversationSessionController', () => { control: emptyControl(), }); - await new ConversationSessionController(deps).submitForChat( - 'chat-2', - 'background message', - ); + await new ConversationSessionController(deps).submitForChat('chat-2', 'background message'); expect(deps.chatState.getCursorForChat).toHaveBeenCalledWith('chat-2'); expect(mockCreateQueuedInput).toHaveBeenCalledWith( diff --git a/web/src/lib/chat/conversation/__tests__/conversation-settings-controller.test.ts b/web/src/lib/chat/conversation/__tests__/conversation-settings-controller.test.ts index 89fcae26e..ef1be30c4 100644 --- a/web/src/lib/chat/conversation/__tests__/conversation-settings-controller.test.ts +++ b/web/src/lib/chat/conversation/__tests__/conversation-settings-controller.test.ts @@ -26,8 +26,6 @@ function chat(): ChatSessionRecord { id: 'chat-1', parentChat: null, projectPath: '/repo', - effectiveProjectKey: '/repo', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', diff --git a/web/src/lib/chat/conversation/__tests__/conversation-slash-command-service.test.ts b/web/src/lib/chat/conversation/__tests__/conversation-slash-command-service.test.ts index 366bf0da9..1985d9a76 100644 --- a/web/src/lib/chat/conversation/__tests__/conversation-slash-command-service.test.ts +++ b/web/src/lib/chat/conversation/__tests__/conversation-slash-command-service.test.ts @@ -48,8 +48,6 @@ function createChat(overrides: Partial = {}): ChatSessionReco return { id: 'chat-1', projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', diff --git a/web/src/lib/chat/conversation/conversation-session-controller.svelte.ts b/web/src/lib/chat/conversation/conversation-session-controller.svelte.ts index e6683656e..abebd98d7 100644 --- a/web/src/lib/chat/conversation/conversation-session-controller.svelte.ts +++ b/web/src/lib/chat/conversation/conversation-session-controller.svelte.ts @@ -43,6 +43,7 @@ import { HandoffForkConfirmationState } from './handoff-fork-confirmation.svelte import { ConversationPermissionService } from './conversation-permission-service.js'; import { AcceptedInputSubmissionService } from '$lib/chat/conversation/accepted-input-submission-service.js'; import type { ConversationSubmissionOutcome } from '$lib/chat/conversation/conversation-submission-outcome.js'; +import type { ProjectTarget } from '$shared/project-resolution'; import { classifySubmission } from '$lib/chat/conversation/submission-classifier.js'; import { errorDetail, @@ -249,8 +250,8 @@ export interface SessionControllerDeps { setIsViewportPinnedToBottom: (v: boolean) => void; setInitialBottomRestorePending: (chatId: string | null) => void; scrollToBottom: () => void; + onProjectUnavailable?: (target: ProjectTarget) => Promise | void; } - export class ConversationSessionController { #lastChatId: string | null = null; #pendingDirectAdmissions = $state.raw>(new Map()); diff --git a/web/src/lib/chat/conversation/submission-routes.ts b/web/src/lib/chat/conversation/submission-routes.ts index e2497a7ad..dc6831f8a 100644 --- a/web/src/lib/chat/conversation/submission-routes.ts +++ b/web/src/lib/chat/conversation/submission-routes.ts @@ -16,6 +16,8 @@ import { steerSubmissionRejection, } from './steer-submission-policy.js'; import * as m from '$lib/paraglide/messages.js'; +import { ApiError } from '$lib/api/client.js'; +import type { ProjectTarget } from '$shared/project-resolution'; type RouteDeps = Pick< SessionControllerDeps, @@ -27,6 +29,7 @@ type RouteDeps = Pick< | 'conversationUi' | 'startupCoordinator' | 'scrollToBottom' + | 'onProjectUnavailable' >; export interface SubmissionContext { @@ -101,6 +104,7 @@ export async function submitQueueRoute( images: context.previousImages, }), refreshControl: () => queue.startControlRefresh(context.chatId), + onRejected: (failure) => refreshUnavailableProject(deps, context, failure), }); } finally { queue.finishSubmission(context.chatId); @@ -140,6 +144,7 @@ export async function submitGoalControlRoute( images: context.previousImages, }), refreshControl: () => queue.startControlRefresh(context.chatId), + onRejected: (failure) => refreshUnavailableProject(deps, context, failure), }); } finally { queue.finishSubmission(context.chatId); @@ -264,9 +269,10 @@ export async function submitDraftRoute( unknownNotice: m.chat_notice_delivery_outcome_unconfirmed(), rejectedNotice: (failure) => m.chat_notice_failed_start_chat({ detail: errorDetail(failure) }), - onRejected: () => { + onRejected: (failure) => { deps.lifecycle.clearTurnStatus(chatId); deps.sessions.applyProcessingEvent(chatId, null); + refreshUnavailableProject(deps, context, failure); }, }); } finally { @@ -323,12 +329,29 @@ export async function submitRunRoute( m.chat_notice_failed_send_message({ detail: errorDetail(failure) }), refreshOnAdmissionConflict: true, refreshControl: () => queue.settleControlRefresh(queue.startControlRefresh(context.chatId)), + onRejected: (failure) => refreshUnavailableProject(deps, context, failure), }); } finally { deps.composerState.isSubmitting = false; } } +function refreshUnavailableProject( + deps: RouteDeps, + context: SubmissionContext, + error: unknown, +): void { + if (!(error instanceof ApiError) || error.errorCode !== 'PROJECT_UNAVAILABLE') return; + const target: ProjectTarget = context.chat.status === 'draft' + ? { kind: 'path', projectPath: context.chat.projectPath } + : { kind: 'chat', chatId: context.chatId, projectPath: context.chat.projectPath }; + try { + void Promise.resolve(deps.onProjectUnavailable?.(target)).catch(() => undefined); + } catch { + // Availability refresh is ancillary to the definitive submission result. + } +} + function requireTranscriptView(deps: RouteDeps, chatId: string): string { const transcriptViewId = deps.chatState.getCursorForChat(chatId).transcriptViewId; if (!transcriptViewId) throw new Error(`Transcript view is not loaded for ${chatId}`); diff --git a/web/src/lib/chat/conversation/submission-settlement.ts b/web/src/lib/chat/conversation/submission-settlement.ts index 011a4b934..7f26a0f13 100644 --- a/web/src/lib/chat/conversation/submission-settlement.ts +++ b/web/src/lib/chat/conversation/submission-settlement.ts @@ -26,7 +26,7 @@ export interface SubmissionFailureOptions { composerRevisionAfterClear?: number | null; refreshControl?: () => Promise; restoreRejected?: () => void; - onRejected?: () => void | Promise; + onRejected?: (error: unknown) => void | Promise; } export async function settleSubmissionFailure( @@ -44,7 +44,7 @@ export async function settleSubmissionFailure( if (admissionConflict && options.refreshControl) await options.refreshControl(); } - if (!outcomeUnknown) await options.onRejected?.(); + if (!outcomeUnknown) await options.onRejected?.(error); if (context.ownsComposer && !outcomeUnknown) { if (options.restoreRejected) { options.restoreRejected(); diff --git a/web/src/lib/chat/sessions/__tests__/ChatSessionsReactivityTestHost.svelte b/web/src/lib/chat/sessions/__tests__/ChatSessionsReactivityTestHost.svelte index 4fa14341b..62652ce15 100644 --- a/web/src/lib/chat/sessions/__tests__/ChatSessionsReactivityTestHost.svelte +++ b/web/src/lib/chat/sessions/__tests__/ChatSessionsReactivityTestHost.svelte @@ -8,8 +8,6 @@ id, parentChat: null, projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title, agentId: 'claude', diff --git a/web/src/lib/chat/sessions/__tests__/chat-sessions-io.test.ts b/web/src/lib/chat/sessions/__tests__/chat-sessions-io.test.ts index 0c9cb3e53..c4d3211a7 100644 --- a/web/src/lib/chat/sessions/__tests__/chat-sessions-io.test.ts +++ b/web/src/lib/chat/sessions/__tests__/chat-sessions-io.test.ts @@ -59,7 +59,6 @@ function makeServerSession(overrides: Partial = {}): ChatSession { model: 'sonnet', title: 'Chat 1', projectPath: '/repo', - effectiveProjectKey: '/repo', orderGroup: 'normal', tags: [], permissionMode: 'default', @@ -136,6 +135,51 @@ describe('ChatSessionsStore IO', () => { expect(store.lastSelectedChatId).toBe('fresh'); }); + it('does not let an older list response overwrite a newer project binding', async () => { + const store = new ChatSessionsStore(); + store.upsertFromServer([makeServerSession({ projectPath: '/workspace/a' })]); + const stale = deferred<{ + sessions: ChatSession[]; + total: number; + lastSelectedChatId: string | null; + }>(); + mockListChats.mockReturnValueOnce(stale.promise); + + const refresh = store.quietRefreshChats(); + store.patchChat('chat-1', { projectPath: '/workspace/b' }); + stale.resolve({ + sessions: [makeServerSession({ projectPath: '/workspace/a' })], + total: 1, + lastSelectedChatId: 'chat-1', + }); + await refresh; + + expect(store.byId['chat-1']?.projectPath).toBe('/workspace/b'); + }); + + it('fences an older list response across an observed A/B/A binding sequence', async () => { + const store = new ChatSessionsStore(); + store.upsertFromServer([makeServerSession({ projectPath: '/workspace/a' })]); + const stale = deferred<{ + sessions: ChatSession[]; + total: number; + lastSelectedChatId: string | null; + }>(); + mockListChats.mockReturnValueOnce(stale.promise); + + const refresh = store.quietRefreshChats(); + store.patchChat('chat-1', { projectPath: '/workspace/b' }); + store.patchChat('chat-1', { projectPath: '/workspace/a' }); + stale.resolve({ + sessions: [makeServerSession({ projectPath: '/workspace/b' })], + total: 1, + lastSelectedChatId: 'chat-1', + }); + await refresh; + + expect(store.byId['chat-1']?.projectPath).toBe('/workspace/a'); + }); + it('projects an archive at the front of the archived list until refresh reconciles it', async () => { const archive = deferred<{ success: boolean; isArchived: boolean }>(); const refresh = deferred<{ diff --git a/web/src/lib/chat/sessions/__tests__/chat-sessions.test.ts b/web/src/lib/chat/sessions/__tests__/chat-sessions.test.ts index 5cdbaeeb8..ae099c40f 100644 --- a/web/src/lib/chat/sessions/__tests__/chat-sessions.test.ts +++ b/web/src/lib/chat/sessions/__tests__/chat-sessions.test.ts @@ -11,7 +11,6 @@ function makeServerSession(overrides: Partial = {}): ChatSession { model: 'opus', title: 'A', projectPath: '/p', - effectiveProjectKey: '/p', orderGroup: 'normal', tags: [], permissionMode: 'default', @@ -33,6 +32,25 @@ function makeServerSession(overrides: Partial = {}): ChatSession { } describe('ChatSessionsStore', () => { + it('publishes each changed project binding through one ordered boundary', () => { + const store = new ChatSessionsStore(); + const listener = vi.fn(); + const unsubscribe = store.onProjectPathChanged(listener); + + store.upsertFromServer([makeServerSession({ id: 'a', projectPath: '/workspace/a' })]); + store.patchChat('a', { projectPath: '/workspace/b' }); + store.patchChat('a', { projectPath: '/workspace/b' }); + store.removeChat('a'); + + expect(listener.mock.calls).toEqual([ + ['a', '/workspace/a'], + ['a', '/workspace/b'], + ['a', null], + ]); + expect(store.projectPathRevision('a')).toBe(3); + unsubscribe(); + }); + it('preserves identity for unchanged records on upsert', () => { const store = new ChatSessionsStore(); diff --git a/web/src/lib/chat/sessions/__tests__/read-receipt-outbox.test.ts b/web/src/lib/chat/sessions/__tests__/read-receipt-outbox.test.ts index a62968812..bacc3b18a 100644 --- a/web/src/lib/chat/sessions/__tests__/read-receipt-outbox.test.ts +++ b/web/src/lib/chat/sessions/__tests__/read-receipt-outbox.test.ts @@ -138,7 +138,6 @@ describe('ReadReceiptOutboxStore', () => { model: 'opus', title: 'A', projectPath: '/p', - effectiveProjectKey: '/p', orderGroup: 'normal', tags: [], permissionMode: 'default', @@ -180,7 +179,6 @@ describe('ReadReceiptOutboxStore', () => { model: 'opus', title: 'Unread', projectPath: '/p', - effectiveProjectKey: '/p', orderGroup: 'normal', tags: [], permissionMode: 'default', @@ -208,7 +206,6 @@ describe('ReadReceiptOutboxStore', () => { model: 'opus', title: 'Read', projectPath: '/p', - effectiveProjectKey: '/p', orderGroup: 'normal', tags: [], permissionMode: 'default', diff --git a/web/src/lib/chat/sessions/chat-project-binding-state.ts b/web/src/lib/chat/sessions/chat-project-binding-state.ts new file mode 100644 index 000000000..1074ee340 --- /dev/null +++ b/web/src/lib/chat/sessions/chat-project-binding-state.ts @@ -0,0 +1,52 @@ +import type { ChatSessionRecord } from '$lib/types/chat-session'; + +export type ProjectPathChangedListener = (chatId: string, projectPath: string | null) => void; + +export class ChatProjectBindingState { + readonly #revisions = new Map(); + readonly #listeners = new Set(); + + captureRevisions(): ReadonlyMap { + return new Map(this.#revisions); + } + + revision(chatId: string): number { + return this.#revisions.get(chatId) ?? 0; + } + + subscribe(listener: ProjectPathChangedListener): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + publish(chatId: string, projectPath: string | null): void { + this.#revisions.set(chatId, this.revision(chatId) + 1); + for (const listener of this.#listeners) listener(chatId, projectPath); + } + + publishIfChanged( + chatId: string, + previousProjectPath: string | undefined, + projectPath: string, + ): void { + if (previousProjectPath !== projectPath) this.publish(chatId, projectPath); + } + + reconcileFetchedRecord( + next: ChatSessionRecord, + previous: ChatSessionRecord | undefined, + capturedRevisions?: ReadonlyMap, + ): ChatSessionRecord { + const requestRevision = capturedRevisions?.get(next.id) ?? 0; + if ( + previous && + capturedRevisions && + requestRevision !== this.revision(next.id) && + next.projectPath !== previous.projectPath + ) { + return { ...next, projectPath: previous.projectPath }; + } + this.publishIfChanged(next.id, previous?.projectPath, next.projectPath); + return next; + } +} diff --git a/web/src/lib/chat/sessions/chat-session-records.ts b/web/src/lib/chat/sessions/chat-session-records.ts new file mode 100644 index 000000000..ba14bfa38 --- /dev/null +++ b/web/src/lib/chat/sessions/chat-session-records.ts @@ -0,0 +1,207 @@ +import type { AgentSettingsEnvelope } from '$shared/agent-integration'; +import { createEmptyAgentSettings, normalizeAgentSettings } from '$shared/agent-settings'; +import type { ChatOrderGroup } from '$shared/chat-list'; +import { normalizePermissionMode, normalizeThinkingMode } from '$shared/chat-modes'; +import { stableJsonStringify } from '$shared/json'; +import type { ChatSessionRecord } from '$lib/types/chat-session'; +import type { ChatSession } from '$lib/types/session'; + +export function normalizeExecutionFields< + T extends { + agentId: string; + permissionMode?: unknown; + thinkingMode?: unknown; + agentSettings?: AgentSettingsEnvelope; + }, +>(value: T): Pick { + return { + permissionMode: normalizePermissionMode(value.permissionMode), + thinkingMode: normalizeThinkingMode(value.thinkingMode), + agentSettings: normalizeAgentSettings( + value.agentId, + value.agentSettings, + createEmptyAgentSettings(value.agentId), + ), + }; +} + +export function toRecord(session: ChatSession): ChatSessionRecord { + if (session.isProcessing !== (session.processingPhase !== null)) { + throw new Error(`Invalid processing projection for chat ${session.id}`); + } + return { + id: session.id, + parentChat: session.parentChat, + projectPath: session.projectPath, + orderGroup: session.orderGroup, + title: session.title, + agentId: session.agentId, + model: session.model, + apiProviderId: session.apiProviderId ?? null, + modelEndpointId: session.modelEndpointId ?? null, + modelProtocol: session.modelProtocol ?? null, + ...normalizeExecutionFields(session), + createdAt: session.activity?.createdAt ?? null, + lastActivityAt: session.activity?.lastActivityAt ?? null, + lastReadAt: session.activity?.lastReadAt ?? null, + isPinned: session.isPinned, + isArchived: session.isArchived ?? false, + isProcessing: session.processingPhase !== null, + processingPhase: session.processingPhase, + canReloadFromNativeHistory: session.canReloadFromNativeHistory === true, + isUnread: session.isUnread ?? false, + status: 'running', + agentOwnershipEpoch: session.agentOwnershipEpoch, + lastMessage: session.preview?.lastMessage || undefined, + tags: session.tags ?? [], + firstMessage: session.preview?.firstMessage || undefined, + }; +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function sameParentChat( + left: ChatSessionRecord['parentChat'], + right: ChatSessionRecord['parentChat'], +): boolean { + if (left === right) return true; + if (left === null || right === null) return false; + if (left.chatId !== right.chatId) return false; + if (left.relation === 'delegation') return right.relation === 'delegation'; + if (right.relation === 'delegation') return false; + return ( + left.relation === right.relation && + left.transcriptViewId === right.transcriptViewId && + left.ordinal === right.ordinal + ); +} + +export function sameRecord(a: ChatSessionRecord, b: ChatSessionRecord): boolean { + return ( + a.id === b.id && + sameParentChat(a.parentChat, b.parentChat) && + a.projectPath === b.projectPath && + a.orderGroup === b.orderGroup && + a.title === b.title && + a.agentId === b.agentId && + a.model === b.model && + a.apiProviderId === b.apiProviderId && + a.modelEndpointId === b.modelEndpointId && + a.modelProtocol === b.modelProtocol && + a.permissionMode === b.permissionMode && + a.thinkingMode === b.thinkingMode && + stableJsonStringify(a.agentSettings) === stableJsonStringify(b.agentSettings) && + a.createdAt === b.createdAt && + a.lastActivityAt === b.lastActivityAt && + a.lastReadAt === b.lastReadAt && + a.isPinned === b.isPinned && + a.isArchived === b.isArchived && + a.isProcessing === b.isProcessing && + a.processingPhase === b.processingPhase && + a.canReloadFromNativeHistory === b.canReloadFromNativeHistory && + a.isUnread === b.isUnread && + a.status === b.status && + a.agentOwnershipEpoch === b.agentOwnershipEpoch && + a.lastMessage === b.lastMessage && + a.firstMessage === b.firstMessage && + arraysEqual(a.tags, b.tags) + ); +} + +export function reconcileActivityProjection( + previous: ChatSessionRecord | undefined, + next: ChatSessionRecord, +): void { + if (!previous) return; + let preservedLocalTimestamp = false; + + if ( + previous.lastActivityAt && + (!next.lastActivityAt || previous.lastActivityAt > next.lastActivityAt) + ) { + next.lastActivityAt = previous.lastActivityAt; + next.lastMessage = previous.lastMessage; + preservedLocalTimestamp = true; + } else if (previous.lastMessage && !next.lastMessage) { + next.lastMessage = previous.lastMessage; + } + + if (previous.lastReadAt && (!next.lastReadAt || previous.lastReadAt > next.lastReadAt)) { + next.lastReadAt = previous.lastReadAt; + preservedLocalTimestamp = true; + } + + if (preservedLocalTimestamp) { + next.isUnread = Boolean( + next.lastActivityAt && (!next.lastReadAt || next.lastActivityAt > next.lastReadAt), + ); + } +} + +export function insertServerEntry( + order: readonly string[], + records: Readonly>, + chatId: string, + group: ChatOrderGroup, + previous: ChatSessionRecord | undefined, +): string[] { + const priorIndex = order.indexOf(chatId); + const without = order.filter((id) => id !== chatId && Boolean(records[id])); + if (previous?.status !== 'draft' && previous?.orderGroup === group && priorIndex >= 0) { + without.splice(Math.min(priorIndex, without.length), 0, chatId); + return without; + } + + const groupRank: Record = { + pinned: 0, + orphan: 1, + normal: 2, + archived: 3, + }; + const draftCount = without.findIndex((id) => records[id]?.status !== 'draft'); + const serverStart = draftCount === -1 ? without.length : draftCount; + let insertionIndex = serverStart; + while (insertionIndex < without.length) { + const record = records[without[insertionIndex]]; + if (!record || record.status === 'draft') { + insertionIndex += 1; + continue; + } + const recordGroup = record.orderGroup ?? 'orphan'; + if (groupRank[recordGroup] >= groupRank[group]) break; + insertionIndex += 1; + } + if (group === 'normal') { + without.splice(insertionIndex, 0, chatId); + return without; + } + if (group === 'archived') { + without.push(chatId); + return without; + } + while ( + insertionIndex < without.length && + records[without[insertionIndex]]?.orderGroup === group + ) { + insertionIndex += 1; + } + without.splice(insertionIndex, 0, chatId); + if (group !== 'orphan') return without; + + const orphanIds = without.filter((id) => records[id]?.orderGroup === 'orphan'); + orphanIds.sort((a, b) => { + const aCreated = records[a]?.createdAt ?? ''; + const bCreated = records[b]?.createdAt ?? ''; + return bCreated.localeCompare(aCreated) || a.localeCompare(b); + }); + let orphanIndex = 0; + return without.map((id) => + records[id]?.orderGroup === 'orphan' ? orphanIds[orphanIndex++] : id, + ); +} diff --git a/web/src/lib/chat/sessions/chat-sessions.svelte.ts b/web/src/lib/chat/sessions/chat-sessions.svelte.ts index 8c5b9f87b..88b732021 100644 --- a/web/src/lib/chat/sessions/chat-sessions.svelte.ts +++ b/web/src/lib/chat/sessions/chat-sessions.svelte.ts @@ -2,13 +2,6 @@ // selection state, and draft lifecycle. Replaces split ownership between // AppShell's local chats array and NavigationStore's selectedChat snapshot. -import { - normalizePermissionMode, - normalizeThinkingMode, -} from '$shared/chat-modes'; -import type { AgentSettingsEnvelope } from '$shared/agent-integration'; -import { createEmptyAgentSettings, normalizeAgentSettings } from '$shared/agent-settings'; -import { stableJsonStringify } from '$shared/json'; import { deleteChat as deleteChatApi, generateChatTitle, @@ -23,12 +16,9 @@ import { updateSessionName } from '$lib/api/settings.js'; import type { ChatSession } from '$lib/types/session'; import type { ChatSessionRecord, ChatStartupConfig } from '$lib/types/chat-session'; import * as m from '$lib/paraglide/messages.js'; -import type { ChatListEntry, ChatOrderGroup } from '$shared/chat-list'; +import type { ChatListEntry } from '$shared/chat-list'; import type { ChatProcessingEntry, ChatProcessingPhase } from '$shared/chat-types'; -import type { - ChatOrderBoundary, - ReorderChatResponse, -} from '$shared/chat-order-contracts'; +import type { ChatOrderBoundary, ReorderChatResponse } from '$shared/chat-order-contracts'; import { chatExecutionDraftStorageKey, removeLocalStorageItem, @@ -37,6 +27,17 @@ import { ChatArchiveProjectionState, type ChatArchiveProjectionOperation, } from './chat-archive-projection-state.svelte.js'; +import { + ChatProjectBindingState, + type ProjectPathChangedListener, +} from './chat-project-binding-state.js'; +import { + insertServerEntry, + normalizeExecutionFields, + reconcileActivityProjection, + sameRecord, + toRecord, +} from './chat-session-records.js'; export interface ChatProcessingTransition { chatId: string; @@ -91,6 +92,8 @@ export interface ChatSessionsPort { patchPreview(chatId: string, content: string, timestamp?: string): void; patchActivity(chatId: string, timestamp: string): void; patchChat(chatId: string, patch: Partial): void; + projectPathRevision(chatId: string): number; + onProjectPathChanged(listener: (chatId: string, projectPath: string | null) => void): () => void; patchLastReadAt(chatId: string, lastReadAt: string): void; isChatProcessing(chatId: string): boolean; processingPhase(chatId: string): ChatProcessingPhase | null; @@ -98,207 +101,6 @@ export interface ChatSessionsPort { reconcileProcessing(entries: readonly ChatProcessingEntry[]): ChatProcessingTransition[]; } -function normalizeExecutionFields< - T extends { - agentId: string; - permissionMode?: unknown; - thinkingMode?: unknown; - agentSettings?: AgentSettingsEnvelope; - }, ->( - value: T, -): Pick { - return { - permissionMode: normalizePermissionMode(value.permissionMode), - thinkingMode: normalizeThinkingMode(value.thinkingMode), - agentSettings: normalizeAgentSettings( - value.agentId, - value.agentSettings, - createEmptyAgentSettings(value.agentId), - ), - }; -} - -function toRecord(session: ChatSession): ChatSessionRecord { - if (session.isProcessing !== (session.processingPhase !== null)) { - throw new Error(`Invalid processing projection for chat ${session.id}`); - } - return { - id: session.id, - parentChat: session.parentChat, - projectPath: session.projectPath, - effectiveProjectKey: session.effectiveProjectKey, - projectIdentityState: 'available', - orderGroup: session.orderGroup, - title: session.title, - agentId: session.agentId, - model: session.model, - apiProviderId: session.apiProviderId ?? null, - modelEndpointId: session.modelEndpointId ?? null, - modelProtocol: session.modelProtocol ?? null, - ...normalizeExecutionFields(session), - createdAt: session.activity?.createdAt ?? null, - lastActivityAt: session.activity?.lastActivityAt ?? null, - lastReadAt: session.activity?.lastReadAt ?? null, - isPinned: session.isPinned, - isArchived: session.isArchived ?? false, - isProcessing: session.processingPhase !== null, - processingPhase: session.processingPhase, - canReloadFromNativeHistory: session.canReloadFromNativeHistory === true, - isUnread: session.isUnread ?? false, - status: 'running', - agentOwnershipEpoch: session.agentOwnershipEpoch, - lastMessage: session.preview?.lastMessage || undefined, - tags: session.tags ?? [], - firstMessage: session.preview?.firstMessage || undefined, - }; -} - -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - -function sameParentChat( - left: ChatSessionRecord['parentChat'], - right: ChatSessionRecord['parentChat'], -): boolean { - if (left === right) return true; - if (left === null || right === null) return false; - if (left.chatId !== right.chatId) return false; - if (left.relation === 'delegation') return right.relation === 'delegation'; - if (right.relation === 'delegation') return false; - return left.relation === right.relation - && left.transcriptViewId === right.transcriptViewId - && left.ordinal === right.ordinal; -} - -function sameRecord(a: ChatSessionRecord, b: ChatSessionRecord): boolean { - return ( - a.id === b.id && - sameParentChat(a.parentChat, b.parentChat) && - a.projectPath === b.projectPath && - a.effectiveProjectKey === b.effectiveProjectKey && - a.projectIdentityState === b.projectIdentityState && - a.orderGroup === b.orderGroup && - a.title === b.title && - a.agentId === b.agentId && - a.model === b.model && - a.apiProviderId === b.apiProviderId && - a.modelEndpointId === b.modelEndpointId && - a.modelProtocol === b.modelProtocol && - a.permissionMode === b.permissionMode && - a.thinkingMode === b.thinkingMode && - stableJsonStringify(a.agentSettings) === stableJsonStringify(b.agentSettings) && - a.createdAt === b.createdAt && - a.lastActivityAt === b.lastActivityAt && - a.lastReadAt === b.lastReadAt && - a.isPinned === b.isPinned && - a.isArchived === b.isArchived && - a.isProcessing === b.isProcessing && - a.processingPhase === b.processingPhase && - a.canReloadFromNativeHistory === b.canReloadFromNativeHistory && - a.isUnread === b.isUnread && - a.status === b.status && - a.agentOwnershipEpoch === b.agentOwnershipEpoch && - a.lastMessage === b.lastMessage && - a.firstMessage === b.firstMessage && - arraysEqual(a.tags, b.tags) - ); -} - -function reconcileActivityProjection( - previous: ChatSessionRecord | undefined, - next: ChatSessionRecord, -): void { - if (!previous) return; - let preservedLocalTimestamp = false; - - if (previous.lastActivityAt && (!next.lastActivityAt || previous.lastActivityAt > next.lastActivityAt)) { - next.lastActivityAt = previous.lastActivityAt; - next.lastMessage = previous.lastMessage; - preservedLocalTimestamp = true; - } else if (previous.lastMessage && !next.lastMessage) { - next.lastMessage = previous.lastMessage; - } - - if (previous.lastReadAt && (!next.lastReadAt || previous.lastReadAt > next.lastReadAt)) { - next.lastReadAt = previous.lastReadAt; - preservedLocalTimestamp = true; - } - - if (preservedLocalTimestamp) { - next.isUnread = Boolean( - next.lastActivityAt && (!next.lastReadAt || next.lastActivityAt > next.lastReadAt), - ); - } -} - -function insertServerEntry( - order: readonly string[], - records: Readonly>, - chatId: string, - group: ChatOrderGroup, - previous: ChatSessionRecord | undefined, -): string[] { - const priorIndex = order.indexOf(chatId); - const without = order.filter((id) => id !== chatId && Boolean(records[id])); - if (previous?.status !== 'draft' && previous?.orderGroup === group && priorIndex >= 0) { - without.splice(Math.min(priorIndex, without.length), 0, chatId); - return without; - } - - const groupRank: Record = { - pinned: 0, - orphan: 1, - normal: 2, - archived: 3, - }; - const draftCount = without.findIndex((id) => records[id]?.status !== 'draft'); - const serverStart = draftCount === -1 ? without.length : draftCount; - let insertionIndex = serverStart; - while (insertionIndex < without.length) { - const record = records[without[insertionIndex]]; - if (!record || record.status === 'draft') { - insertionIndex += 1; - continue; - } - const recordGroup = record.orderGroup ?? 'orphan'; - if (groupRank[recordGroup] >= groupRank[group]) break; - insertionIndex += 1; - } - if (group === 'normal') { - without.splice(insertionIndex, 0, chatId); - return without; - } - if (group === 'archived') { - without.push(chatId); - return without; - } - while ( - insertionIndex < without.length && - records[without[insertionIndex]]?.orderGroup === group - ) { - insertionIndex += 1; - } - without.splice(insertionIndex, 0, chatId); - if (group !== 'orphan') return without; - - const orphanIds = without.filter((id) => records[id]?.orderGroup === 'orphan'); - orphanIds.sort((a, b) => { - const aCreated = records[a]?.createdAt ?? ''; - const bCreated = records[b]?.createdAt ?? ''; - return bCreated.localeCompare(aCreated) || a.localeCompare(b); - }); - let orphanIndex = 0; - return without.map((id) => - records[id]?.orderGroup === 'orphan' ? orphanIds[orphanIndex++] : id, - ); -} - export class ChatSessionsStore implements ChatSessionsPort { #baseById = $state.raw>({}); #baseOrder = $state.raw([]); @@ -320,6 +122,7 @@ export class ChatSessionsStore implements ChatSessionsPort { #processingSnapshot: Map | null = null; readonly #processingOverrides = new Map(); readonly #archiveProjection = new ChatArchiveProjectionState(); + readonly #projectBindings = new ChatProjectBindingState(); #byId = $derived.by(() => this.#archiveProjection.projectRecords(this.#baseById)); #order = $derived.by(() => this.#archiveProjection.projectOrder(this.#baseOrder, this.#byId)); @@ -373,10 +176,11 @@ export class ChatSessionsStore implements ChatSessionsPort { if (showLoading) this.isLoadingChats = true; try { const fetchChats = this.#deps.listChats ?? listChats; + const projectPathRevisions = this.#projectBindings.captureRevisions(); const res = await fetchChats(); this.lastSelectedChatId = typeof res.lastSelectedChatId === 'string' ? res.lastSelectedChatId : null; - this.upsertFromServer(res.sessions ?? []); + this.#upsertFromServer(res.sessions ?? [], projectPathRevisions); this.#latestSuccessfulFetchGeneration = fetchGeneration; } catch (err) { const prefix = showLoading ? 'Failed to fetch chats' : 'Quiet refresh failed'; @@ -446,9 +250,7 @@ export class ChatSessionsStore implements ChatSessionsPort { if (operation.chatIds.length === 0) return; const toggleRemoteArchive = this.#deps.toggleArchive ?? toggleArchiveApi; const settlements = await Promise.all( - operation.chatIds.map((chatId) => - this.#settleArchiveMutation(chatId, toggleRemoteArchive), - ), + operation.chatIds.map((chatId) => this.#settleArchiveMutation(chatId, toggleRemoteArchive)), ); await this.#refresh(false); @@ -484,8 +286,7 @@ export class ChatSessionsStore implements ChatSessionsPort { result, // Only a fetch started after this mutation settles can reconcile it. requiredRefreshGeneration: this.#nextFetchGeneration + 1, - serverEntryGenerationAtSettlement: - this.#serverEntryGenerationByChatId.get(chatId) ?? 0, + serverEntryGenerationAtSettlement: this.#serverEntryGenerationByChatId.get(chatId) ?? 0, }; } @@ -659,6 +460,13 @@ export class ChatSessionsStore implements ChatSessionsPort { * for unchanged records to avoid unnecessary re-renders. Drafts that the * server now owns get their startup config cleaned up. */ upsertFromServer(sessions: ChatSession[]): void { + this.#upsertFromServer(sessions); + } + + #upsertFromServer( + sessions: ChatSession[], + requestProjectPathRevisions?: ReadonlyMap, + ): void { const nextById: Record = {}; const nextOrder: string[] = []; const previousServerChatIds = new Set( @@ -677,10 +485,11 @@ export class ChatSessionsStore implements ChatSessionsPort { const startupIdsToRemove: string[] = []; for (const session of sessions) { - const next = toRecord(session); + let next = toRecord(session); next.processingPhase = this.#resolveProcessing(next.id, next.processingPhase); next.isProcessing = next.processingPhase !== null; const prev = this.#baseById[next.id]; + next = this.#projectBindings.reconcileFetchedRecord(next, prev, requestProjectPathRevisions); reconcileActivityProjection(prev, next); if (prev && sameRecord(prev, next)) { nextById[next.id] = prev; @@ -707,6 +516,7 @@ export class ChatSessionsStore implements ChatSessionsPort { const serverIdSet = new Set(nextOrder); for (const chatId of previousServerChatIds) { if (serverIdSet.has(chatId)) continue; + this.#projectBindings.publish(chatId, null); this.#processingOverrides.delete(chatId); this.#processingSnapshot?.delete(chatId); } @@ -736,8 +546,6 @@ export class ChatSessionsStore implements ChatSessionsPort { id, parentChat: null, projectPath, - effectiveProjectKey: null, - projectIdentityState: 'pending', orderGroup: null, title: normalizedStartup.firstMessage.trim() || m.chat_sessions_new_session(), agentId: normalizedStartup.agentId, @@ -795,6 +603,7 @@ export class ChatSessionsStore implements ChatSessionsPort { #mergeServerEntry(entry: ChatListEntry, clearStartup: boolean): void { const next = toRecord(entry); const previous = this.#baseById[entry.id]; + this.#projectBindings.publishIfChanged(entry.id, previous?.projectPath, next.projectPath); reconcileActivityProjection(previous, next); next.processingPhase = this.#resolveProcessing(entry.id, next.processingPhase); next.isProcessing = next.processingPhase !== null; @@ -821,6 +630,7 @@ export class ChatSessionsStore implements ChatSessionsPort { this.#processingSnapshot?.delete(chatId); removeLocalStorageItem(chatExecutionDraftStorageKey(chatId)); if (!this.#baseById[chatId]) return; + this.#projectBindings.publish(chatId, null); const nextById = { ...this.#baseById }; delete nextById[chatId]; @@ -854,10 +664,11 @@ export class ChatSessionsStore implements ChatSessionsPort { ? Boolean(lastActivityAt && (!chat.lastReadAt || lastActivityAt > chat.lastReadAt)) : chat.isUnread; if ( - (chat.lastMessage || '') === content - && chat.lastActivityAt === lastActivityAt - && chat.isUnread === isUnread - ) return; + (chat.lastMessage || '') === content && + chat.lastActivityAt === lastActivityAt && + chat.isUnread === isUnread + ) + return; this.#baseById = { ...this.#baseById, [chatId]: { ...chat, lastMessage: content, lastActivityAt, isUnread }, @@ -880,6 +691,9 @@ export class ChatSessionsStore implements ChatSessionsPort { patchChat(chatId: string, patch: Partial): void { const chat = this.#baseById[chatId]; if (!chat) return; + if (typeof patch.projectPath === 'string' && patch.projectPath !== chat.projectPath) { + this.#projectBindings.publish(chatId, patch.projectPath); + } const nextChat = { ...chat, ...patch, @@ -891,18 +705,23 @@ export class ChatSessionsStore implements ChatSessionsPort { }; } + projectPathRevision(chatId: string): number { + return this.#projectBindings.revision(chatId); + } + + onProjectPathChanged(listener: ProjectPathChangedListener): () => void { + return this.#projectBindings.subscribe(listener); + } + /** Applies a server-confirmed lastReadAt and recomputes isUnread locally. * Avoids the race where the server computes isUnread from a lastActivity * that advances during streaming, overwriting the client's optimistic false. */ patchLastReadAt(chatId: string, lastReadAt: string): void { const chat = this.#baseById[chatId]; if (!chat) return; - const reconciledLastReadAt = chat.lastReadAt && chat.lastReadAt > lastReadAt - ? chat.lastReadAt - : lastReadAt; - const isUnread = Boolean( - chat.lastActivityAt && chat.lastActivityAt > reconciledLastReadAt, - ); + const reconciledLastReadAt = + chat.lastReadAt && chat.lastReadAt > lastReadAt ? chat.lastReadAt : lastReadAt; + const isUnread = Boolean(chat.lastActivityAt && chat.lastActivityAt > reconciledLastReadAt); if (chat.lastReadAt === reconciledLastReadAt && chat.isUnread === isUnread) return; this.#baseById = { ...this.#baseById, diff --git a/web/src/lib/components/chat-map/__tests__/ChatMapPanel.test.ts b/web/src/lib/components/chat-map/__tests__/ChatMapPanel.test.ts index 03e856bad..6a725dea3 100644 --- a/web/src/lib/components/chat-map/__tests__/ChatMapPanel.test.ts +++ b/web/src/lib/components/chat-map/__tests__/ChatMapPanel.test.ts @@ -19,8 +19,6 @@ function chat(id: string, overrides: Partial = {}): ChatSessi id, parentChat: null, projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: `Chat ${id}`, agentId: 'claude', diff --git a/web/src/lib/components/chat/ChatSurface.svelte b/web/src/lib/components/chat/ChatSurface.svelte index 6186424a4..c0b13881f 100644 --- a/web/src/lib/components/chat/ChatSurface.svelte +++ b/web/src/lib/components/chat/ChatSurface.svelte @@ -72,13 +72,7 @@ let prepareConversationHide: (() => void) | null = $state(null); const selectedChat = $derived(sessions.selectedChat); - const hasUsableChatContext = $derived( - Boolean( - selectedChat && - selectedChat.projectIdentityState === 'available' && - selectedChat.effectiveProjectKey, - ), - ); + const hasUsableChatContext = $derived(Boolean(selectedChat)); const chatSurfacePresentation = $derived( resolveChatSurfacePresentation(selectedChat, sessions.isLoadingChats), ); @@ -193,6 +187,10 @@ {transcriptCache} {reserveMobileToolbar} isVisible={conversationWorkspaceVisible} + onChooseProjectFolder={(chatId) => { + const chat = sessions.byId[chatId]; + if (chat) chatActions.requestProjectPath(chat); + }} /> diff --git a/web/src/lib/components/chat/ConversationPanel.svelte b/web/src/lib/components/chat/ConversationPanel.svelte index 944137343..e4bd33ee8 100644 --- a/web/src/lib/components/chat/ConversationPanel.svelte +++ b/web/src/lib/components/chat/ConversationPanel.svelte @@ -118,7 +118,7 @@ sort: quickGitBranches.branchSort, isOpen: exposesCurrentBranchState && quickGitBranches.showBranchDropdown, isLoading: exposesCurrentBranchState && quickGitBranches.isLoadingBranches, - onToggle: () => actions?.toggleBranch(surfaceId, chatId), + onToggle: () => void actions?.toggleBranch(surfaceId, chatId), onClose: () => actions?.closeBranch(surfaceId, chatId), onCreateBranch: () => actions?.createBranch(surfaceId, chatId), onSwitchBranch: (branch) => actions?.switchBranch(surfaceId, chatId, branch), diff --git a/web/src/lib/components/chat/ConversationWorkspace.svelte b/web/src/lib/components/chat/ConversationWorkspace.svelte index 29a2dd430..7d75489bc 100644 --- a/web/src/lib/components/chat/ConversationWorkspace.svelte +++ b/web/src/lib/components/chat/ConversationWorkspace.svelte @@ -28,6 +28,7 @@ import { mountConversationRouter } from '$lib/chat/conversation/conversation-router-adapter.svelte.js'; import { applyChatMessageBatchActivity } from '$lib/chat/sessions/chat-message-batch-activity.js'; import { ConversationSessionController } from '$lib/chat/conversation/conversation-session-controller.svelte.js'; + import type { ConversationPanelRegistration } from '$lib/chat/conversation/conversation-panel-registry.svelte.js'; import { requiresQueuedSubmission } from '$lib/chat/conversation/submission-classifier.js'; import { CurrentConversationPanelTranscript } from '$lib/chat/conversation/current-conversation-panel-transcript.js'; import { CurrentConversationLifecycle } from '$lib/chat/conversation/current-conversation-lifecycle.js'; @@ -55,6 +56,7 @@ getWorkspaceCoordinator, getWorkspaceShortcuts, getGitQuickSummary, + getProjectResolution, getGitBranchActions, getChatDrafts, getConversationUi, @@ -77,6 +79,7 @@ onRegisterPrepareHide?: (prepare: (() => void) | null) => void; onRegisterPanelActions?: (actions: ConversationPanelActions | null) => void; onComposerHeightChange?: (height: number) => void; + onChooseProjectFolder?: (chatId: string) => void; subagentToolbar: SubagentToolbarState; transcriptCache?: ChatTranscriptCache; reserveMobileToolbar?: boolean; @@ -101,6 +104,7 @@ onRegisterPrepareHide, onRegisterPanelActions, onComposerHeightChange, + onChooseProjectFolder, subagentToolbar, transcriptCache: providedTranscriptCache, reserveMobileToolbar = false, @@ -166,6 +170,9 @@ }); const quickGit = getGitQuickSummary(); const quickGitBranches = getGitBranchActions(); + const projectResolution = getProjectResolution(); + let branchCommandGeneration = 0; + let branchDropdownGeneration: number | null = null; const startupCoordinator = new StartupCoordinator(); const reconnectCoordinator = new ChatReconnectCoordinator({ ws, @@ -293,6 +300,19 @@ }, }, requestProcessingSnapshot: (source) => ws.requestProcessingSnapshot(source), + onProjectUnavailable: async (target) => { + if ( + target.kind === 'chat' && + sessions.byId[target.chatId]?.projectPath !== target.projectPath + ) + return; + const lease = projectResolution.retain(target); + try { + await lease.retry(); + } finally { + lease.release(); + } + }, setIsViewportPinnedToBottom: (v) => { currentPanel()?.scroll.setPinnedToBottom(v); }, @@ -381,18 +401,15 @@ }, toggleBranch(surfaceId, chatId) { assertRenderedPanel(surfaceId, chatId); - toggleCommitBranchDropdown(chatId); + return toggleCommitBranchDropdown(surfaceId, chatId); }, closeBranch(surfaceId, chatId) { assertRenderedPanel(surfaceId, chatId); - quickGitBranches.closeBranchDropdown(); + closeCommitBranchDropdown(); }, createBranch(surfaceId, chatId) { assertRenderedPanel(surfaceId, chatId); - const chat = sessions.byId[chatId]; - if (chat?.projectPath && chat.effectiveProjectKey) { - quickGitBranches.openNewBranchDialog(chat.projectPath, surfaceId, chat.effectiveProjectKey); - } + void openNewBranchDialog(surfaceId, chatId); }, switchBranch(surfaceId, chatId, branch) { assertRenderedPanel(surfaceId, chatId); @@ -634,17 +651,95 @@ }); } - function toggleCommitBranchDropdown(chatId: string): void { + async function toggleCommitBranchDropdown( + surfaceId: ChatViewSurfaceId, + chatId: string, + ): Promise { + const command = beginBranchCommand(surfaceId, chatId, true); + if (!command) return; const projectPath = sessions.byId[chatId]?.projectPath; if (!projectPath) return; if ( quickGitBranches.currentProjectPath === projectPath && quickGitBranches.showBranchDropdown ) { - quickGitBranches.closeBranchDropdown(); + closeCommitBranchDropdown(); return; } - void quickGitBranches.openBranchDropdown(projectPath); + const project = await resolveChatProject(chatId); + if (!project || !ownsBranchCommand(command)) return; + quickGitBranches.setProject( + project.projectPath, + quickGit.summaryFor(project.projectPath)?.branch, + project.effectiveProjectKey, + ); + await quickGitBranches.openBranchDropdown(project.projectPath, project.effectiveProjectKey); + if (command.generation === branchCommandGeneration && !ownsBranchCommand(command)) { + quickGitBranches.closeBranchDropdown(); + } + } + + type BranchCommand = { + readonly generation: number; + readonly surfaceId: ChatViewSurfaceId; + readonly chatId: string; + readonly focusOwnerRevision: number; + readonly panel: ConversationPanelRegistration; + }; + + function beginBranchCommand( + surfaceId: ChatViewSurfaceId, + chatId: string, + opensDropdown = false, + ): BranchCommand | null { + const panel = conversationPanels.panel(surfaceId); + const owner = workspace.focusOwner; + if ( + !panel || + panel.chatId !== chatId || + owner.kind === 'chat-list' || + owner.surfaceId !== surfaceId + ) { + return null; + } + const generation = ++branchCommandGeneration; + if (opensDropdown) branchDropdownGeneration = generation; + return { + generation, + surfaceId, + chatId, + focusOwnerRevision: workspace.focusOwnerRevision, + panel, + }; + } + + function ownsBranchCommand(command: BranchCommand): boolean { + return ( + command.generation === branchCommandGeneration && + workspace.focusOwnerRevision === command.focusOwnerRevision && + workspace.focusOwner.kind !== 'chat-list' && + workspace.focusOwner.surfaceId === command.surfaceId && + conversationPanels.panel(command.surfaceId) === command.panel && + command.panel.chatId === command.chatId + ); + } + + function closeCommitBranchDropdown(): void { + if (branchDropdownGeneration === branchCommandGeneration) branchCommandGeneration += 1; + branchDropdownGeneration = null; + quickGitBranches.closeBranchDropdown(); + } + + async function openNewBranchDialog(surfaceId: ChatViewSurfaceId, chatId: string): Promise { + const command = beginBranchCommand(surfaceId, chatId); + if (!command) return; + const project = await resolveChatProject(chatId); + if (!project || !ownsBranchCommand(command)) return; + quickGitBranches.openNewBranchDialog( + project.projectPath, + surfaceId, + project.effectiveProjectKey, + ); } async function switchCommitBranch( @@ -652,17 +747,44 @@ chatId: string, branch: string, ): Promise { - const chat = sessions.byId[chatId]; - if (!chat?.projectPath || !chat.effectiveProjectKey) return; + const command = beginBranchCommand(surfaceId, chatId); + if (!command) return; + const project = await resolveChatProject(chatId); + if (!project || !ownsBranchCommand(command)) return; await quickGitBranches.switchBranch( - chat.projectPath, + project.projectPath, branch, undefined, surfaceId, - chat.effectiveProjectKey, + project.effectiveProjectKey, ); } + async function resolveChatProject(chatId: string): Promise<{ + projectPath: string; + effectiveProjectKey: string; + } | null> { + const chat = sessions.byId[chatId]; + if (!chat?.projectPath) return null; + const target = + chat.status === 'draft' + ? { kind: 'path' as const, projectPath: chat.projectPath } + : { kind: 'chat' as const, chatId, projectPath: chat.projectPath }; + const lease = projectResolution.retain(target); + try { + await lease.resolve(); + if (sessions.byId[chatId]?.projectPath !== target.projectPath) return null; + return lease.snapshot.kind === 'available' + ? { + projectPath: target.projectPath, + effectiveProjectKey: lease.snapshot.effectiveProjectKey, + } + : null; + } finally { + lease.release(); + } + } + let composerHost = $state(null); $effect(() => { @@ -699,6 +821,7 @@ {composerEditorOpenRequestId} onsubmit={onSubmit} {onSteerPreferredSubmit} + {onChooseProjectFolder} onModelChange={(next) => controller.handleModelSelectionChange(next)} onPermissionModeChange={(m) => controller.handlePermissionModeChange(m)} onThinkingModeChange={(m) => controller.handleThinkingModeChange(m)} diff --git a/web/src/lib/components/chat/FileMentionMenu.svelte b/web/src/lib/components/chat/FileMentionMenu.svelte index d308bc0c8..4c1643da8 100644 --- a/web/src/lib/components/chat/FileMentionMenu.svelte +++ b/web/src/lib/components/chat/FileMentionMenu.svelte @@ -12,13 +12,24 @@ interface Props { projectPath: string; isVisible: boolean; + projectPending?: boolean; + projectUnavailable?: boolean; query: string; onSelect: (filePath: string) => void; onClose: () => void; position?: { top: number; left: number }; } - let { projectPath, isVisible, query, onSelect, onClose, position }: Props = $props(); + let { + projectPath, + isVisible, + projectPending = false, + projectUnavailable = false, + query, + onSelect, + onClose, + position, + }: Props = $props(); const transientLayers = getTransientLayers(); const layerId = allocateTransientLayerId('file-mention'); @@ -29,22 +40,24 @@ let loadFailed = $state(false); let fetchedForProject = ''; + let activeLoad: AbortController | null = null; // Defers fetch until the menu becomes visible for the first time. // Re-fetches when projectPath changes. $effect(() => { if (!projectPath || !isVisible) return; if (fetchedForProject === projectPath) return; - fetchedForProject = projectPath; isLoading = true; loadFailed = false; const controller = new AbortController(); + activeLoad = controller; getFileList({ projectPath }, { signal: controller.signal }) .then((files) => { if (!controller.signal.aborted) { allFiles = files; + fetchedForProject = projectPath; } }) .catch((err) => { @@ -55,12 +68,17 @@ } }) .finally(() => { - if (!controller.signal.aborted) { - isLoading = false; - } + if (activeLoad !== controller) return; + activeLoad = null; + isLoading = false; }); - return () => controller.abort(); + return () => { + controller.abort(); + if (activeLoad !== controller) return; + activeLoad = null; + isLoading = false; + }; }); function normalizeSlashes(value: string): string { @@ -92,6 +110,7 @@ // Filters files by query (case-insensitive), capped at 10 results. let filteredFiles = $derived.by(() => { + if (!projectPath || projectPending || projectUnavailable || isLoading || loadFailed) return []; if (!query) return selectableFiles.slice(0, 10); const lowerQuery = query.toLowerCase(); @@ -176,11 +195,11 @@ style:left={position ? `${position.left}px` : undefined} >
    - {#if isLoading} + {#if isLoading || projectPending}
  • {m.filetree_loading()}
  • - {:else if loadFailed} + {:else if loadFailed || projectUnavailable}
  • {m.filetree_check_project_path()}
  • diff --git a/web/src/lib/components/chat/PromptComposer.svelte b/web/src/lib/components/chat/PromptComposer.svelte index dd10bc379..e47e68f2e 100644 --- a/web/src/lib/components/chat/PromptComposer.svelte +++ b/web/src/lib/components/chat/PromptComposer.svelte @@ -19,6 +19,7 @@ getSnippets, getTransientLayers, getWorkspaceShortcuts, + getProjectResolution, } from '$lib/context'; import { chatAttachmentAccept, @@ -73,22 +74,11 @@ import { CHAT_FILE_ATTACHMENT_MIME_TYPES } from '@garcon/common/attachments'; import ImagePlus from '@lucide/svelte/icons/image-plus'; import X from '@lucide/svelte/icons/x'; - import type { PermissionMode, ThinkingMode } from '$lib/types/chat'; - import type { AgentSettingDescriptor } from '$shared/agent-integration'; - import type { JsonValue } from '$shared/json'; - import type { ResendCandidate } from '$shared/chat-view'; import ComposerModelSelector from '$lib/components/model-selector/ComposerModelSelector.svelte'; import { composerModelSelectorMode } from '$lib/components/model-selector/composer-model-selector-mode'; import { buildModelSelectorRecents } from '$lib/components/model-selector/model-selector-recents'; - import type { - ModelSelectorChange, - ModelSelectorMode, - } from '$lib/components/model-selector/model-selector-types'; - import { - snippetTemplateUsesArguments, - type Snippet, - type SnippetExpansionContext, - } from '$shared/snippets'; + import type { ModelSelectorMode } from '$lib/components/model-selector/model-selector-types'; + import { snippetTemplateUsesArguments, type Snippet } from '$shared/snippets'; import { transientLayerAttachment } from '$lib/workspace/transient-layer-action.js'; import { allocateTransientLayerId } from '$lib/workspace/transient-layer-id.js'; import { isDirectAgentId, nonDirectAgentIds } from '$lib/agents/direct-agents.js'; @@ -96,24 +86,9 @@ import { PromptComposerAttachmentController } from './prompt-composer-attachment-controller.js'; import { PromptComposerRefinementController } from './prompt-composer-refinement-controller.js'; import { PromptComposerFocusDelivery } from './prompt-composer-focus-delivery.svelte.js'; - interface Props { - onsubmit: () => void; - onSteerPreferredSubmit: () => void; - onModelChange?: (selection: ModelSelectorChange) => void; - onPermissionModeChange?: (mode: PermissionMode) => void; - onThinkingModeChange?: (mode: ThinkingMode) => void; - onAgentSettingChange?: (descriptor: AgentSettingDescriptor, value: JsonValue) => void; - resendCandidates?: readonly ResendCandidate[]; - onExcludeResendCandidate?: (ordinal: number) => void; - directAdmissionPending?: boolean; - requiresQueuedSubmission?: boolean; - // False when the composer is mounted but hidden (e.g. the Git tab is - // active). Focus requests must not be consumed while hidden, since - // focusing a display:none textarea is a silent no-op. - isVisible?: boolean; - isPresented?: boolean; - composerEditorOpenRequestId?: number; - } + import { PromptComposerProjectState } from './prompt-composer-project-state.svelte.js'; + import type { PromptComposerProps } from './prompt-composer-props.js'; + import ProjectAvailabilityNotice from '$lib/components/workspace/ProjectAvailabilityNotice.svelte'; let { onsubmit, @@ -129,7 +104,8 @@ isVisible = true, isPresented: isPresentedOverride, composerEditorOpenRequestId = 0, - }: Props = $props(); + onChooseProjectFolder, + }: PromptComposerProps = $props(); const isPresented = $derived(isPresentedOverride ?? isVisible); const composerState = getComposerState(); const agentState = getAgentState(); @@ -142,6 +118,8 @@ const snippets = getSnippets(); const transientLayers = getTransientLayers(); const workspaceShortcuts = getWorkspaceShortcuts(); + const projectResolution = getProjectResolution(); + const ui = new PromptComposerUiState(); const snippetExpansion = new SnippetExpansionController(); const snippetExpansionLayer = transientLayerAttachment({ registry: transientLayers, @@ -165,13 +143,30 @@ const focusDelivery = new PromptComposerFocusDelivery(); const snippetInteractionKey = $derived.by(() => { const chat = sessions.selectedChat; - return chat - ? [chat.id, chat.status, chat.projectPath, chat.effectiveProjectKey].join('\u0000') - : ''; + return chat ? [chat.id, chat.status, chat.projectPath].join('\u0000') : ''; }); const snippetContextHint = $derived( sessions.selectedChat?.projectPath.trim() ? null : m.snippets_palette_context_hint(), ); + const projectState = new PromptComposerProjectState({ + get selectedChat() { + return sessions.selectedChat; + }, + get completionDemand() { + return ui.showFileMenu || ui.showSlashMenu; + }, + projectResolution, + }); + const selectedProjectTarget = $derived(projectState.target); + const selectedProjectResolution = $derived(projectState.snapshot); + const completionProjectPath = $derived(projectState.completionProjectPath); + const canChooseProjectFolder = $derived( + Boolean( + onChooseProjectFolder && + sessions.selectedChat && + modelCatalog.supportsUpdateProjectPath(sessions.selectedChat.agentId), + ), + ); function requestComposerFocusForChat(chatId: string | null): void { focusDelivery.request( @@ -185,8 +180,6 @@ tick().then(() => requestComposerFocusForChat(sessions.selectedChatId)); }); - // Ephemeral UI state extracted to companion class. - const ui = new PromptComposerUiState(); const promptRefinement = new PromptComposerRefinementController({ composer: composerState, sessions, @@ -286,6 +279,7 @@ onDestroy(() => { destroyed = true; + projectState.destroy(); snippetExpansion.cancel(); promptRefinement.destroy(); imageAttachments.revokeAll(); @@ -403,15 +397,6 @@ autoResize(); } - function snippetContext(): SnippetExpansionContext | null { - const chat = sessions.selectedChat; - const projectPath = chat?.projectPath.trim(); - if (!chat || !projectPath) return null; - return chat.status === 'draft' - ? { type: 'new-chat', chatId: chat.id, projectPath } - : { type: 'chat', chatId: chat.id }; - } - function snippetErrorDetail(error: unknown): string { if (error instanceof ApiError) return error.details || error.message; return error instanceof Error ? error.message : String(error); @@ -438,24 +423,23 @@ ui.closeSlashMenu(); ui.closeFileMenu(); composerState.isDragActive = false; - const context = snippetContext(); - if (!context) { - notifications.error(m.chat_new_chat_errors_project_path_required()); - await settleComposerAfterSnippet(); - return 'cancelled'; - } - const chatId = sessions.selectedChatId; - const projectPath = sessions.selectedChat?.projectPath.trim() ?? null; const sourceText = composerState.inputText; const start = range?.start ?? textarea.selectionStart; const end = range?.end ?? textarea.selectionEnd; try { - const result = await snippetExpansion.run({ - shortName: snippet.shortName, - arguments: { type: 'value', value: argumentsText }, - context, + const result = await snippetExpansion.runPrepared(snippet.shortName, async (signal) => { + const operation = await projectState.resolveSnippetContext(signal); + return { + request: { + shortName: snippet.shortName, + arguments: { type: 'value', value: argumentsText }, + context: operation.context, + }, + prepared: operation, + }; }); if (result.kind !== 'expanded') return 'cancelled'; + const operation = result.prepared; if ( result.response.snippetId !== snippet.id || result.response.snippetUpdatedAt !== snippet.updatedAt @@ -466,9 +450,9 @@ return 'cancelled'; } if ( - sessions.selectedChatId !== chatId || - sessions.selectedChat?.projectPath.trim() !== projectPath || - result.response.contextProjectPath !== projectPath || + sessions.selectedChatId !== operation.chatId || + sessions.selectedChat?.projectPath.trim() !== operation.projectPath || + result.response.contextProjectPath !== operation.projectPath || composerState.inputText !== sourceText ) return 'cancelled'; @@ -493,28 +477,28 @@ async function expandSnippetInvocation( command: Extract, ): Promise { - const context = snippetContext(); - if (!context) { - notifications.error(m.chat_new_chat_errors_project_path_required()); - return; - } - const chatId = sessions.selectedChatId; - const projectPath = sessions.selectedChat?.projectPath.trim() ?? null; const sourceText = composerState.inputText; ui.closeSlashMenu(); ui.closeFileMenu(); composerState.isDragActive = false; try { - const result = await snippetExpansion.run({ - shortName: command.shortName, - arguments: command.arguments, - context, + const result = await snippetExpansion.runPrepared(command.shortName, async (signal) => { + const operation = await projectState.resolveSnippetContext(signal); + return { + request: { + shortName: command.shortName, + arguments: command.arguments, + context: operation.context, + }, + prepared: operation, + }; }); if (result.kind !== 'expanded') return; + const operation = result.prepared; if ( - sessions.selectedChatId !== chatId || - sessions.selectedChat?.projectPath.trim() !== projectPath || - result.response.contextProjectPath !== projectPath || + sessions.selectedChatId !== operation.chatId || + sessions.selectedChat?.projectPath.trim() !== operation.projectPath || + result.response.contextProjectPath !== operation.projectPath || composerState.inputText !== sourceText ) return; @@ -612,9 +596,7 @@ composerState.isSubmitting && sessions.selectedChat?.status === 'draft', ); const isQueueMode = $derived(requiresQueuedSubmission); - const hasQueuedAttachmentConflict = $derived( - isQueueMode && composerState.images.length > 0, - ); + const hasQueuedAttachmentConflict = $derived(isQueueMode && composerState.images.length > 0); const isDisabled = $derived(isDraftStartupSubmitting); const canSubmit = $derived( @@ -708,8 +690,15 @@ > ui.closeFileMenu()} @@ -919,9 +908,16 @@ + {#if selectedProjectTarget && selectedProjectResolution.kind === 'unavailable'} +
    + projectState.retry()} + onChooseFolder={canChooseProjectFolder && sessions.selectedChat + ? () => onChooseProjectFolder?.(sessions.selectedChat!.id) + : undefined} + /> +
    + {:else if selectedProjectTarget && selectedProjectResolution.kind === 'request-failed'} +
    + projectState.retry()} + onChooseFolder={canChooseProjectFolder && sessions.selectedChat + ? () => onChooseProjectFolder?.(sessions.selectedChat!.id) + : undefined} + /> +
    + {/if}
    {@render composerFrame()}
    diff --git a/web/src/lib/components/chat/SlashCommandMenu.svelte b/web/src/lib/components/chat/SlashCommandMenu.svelte index d16025db4..e69e8799d 100644 --- a/web/src/lib/components/chat/SlashCommandMenu.svelte +++ b/web/src/lib/components/chat/SlashCommandMenu.svelte @@ -22,6 +22,8 @@ projectPath: string; chatId?: string | null; isVisible: boolean; + projectPending?: boolean; + projectUnavailable?: boolean; query: string; supportsFork: boolean; supportsSteering: boolean; @@ -37,6 +39,8 @@ projectPath, chatId = null, isVisible, + projectPending = false, + projectUnavailable = false, query, supportsFork, supportsSteering, @@ -56,6 +60,7 @@ let loadFailed = $state(false); let fetchedKey = ''; + let activeLoad: AbortController | null = null; // Defers fetch until the menu becomes visible for the first time. // Re-fetches when the agent/project identity changes. @@ -63,16 +68,17 @@ const key = `${agent}::${chatId ?? ''}::${projectPath}`; if (!projectPath || !isVisible) return; if (fetchedKey === key) return; - fetchedKey = key; isLoading = true; loadFailed = false; const controller = new AbortController(); + activeLoad = controller; getSlashCommands({ agent, chatId, projectPath }, { signal: controller.signal }) .then((commands) => { if (!controller.signal.aborted) { allCommands = commands; + fetchedKey = key; } }) .catch((err) => { @@ -83,12 +89,17 @@ } }) .finally(() => { - if (!controller.signal.aborted) { - isLoading = false; - } + if (activeLoad !== controller) return; + activeLoad = null; + isLoading = false; }); - return () => controller.abort(); + return () => { + controller.abort(); + if (activeLoad !== controller) return; + activeLoad = null; + isLoading = false; + }; }); // Agent-discovered commands are appended after visible client built-ins. @@ -102,9 +113,18 @@ return true; }); const builtinNames = new Set(builtins.map((command) => command.name)); - const discovered = allCommands.filter( - (command) => command.name !== 'in' && !builtinNames.has(command.name), - ); + const key = `${agent}::${chatId ?? ''}::${projectPath}`; + const discovered = + projectPath && + !projectPending && + !projectUnavailable && + !isLoading && + !loadFailed && + fetchedKey === key + ? allCommands.filter( + (command) => command.name !== 'in' && !builtinNames.has(command.name), + ) + : []; return [...builtins, ...discovered]; }); @@ -279,11 +299,11 @@ {/each}
- {#if isLoading} + {#if isLoading || projectPending}
{m.chat_slash_command_loading()}
- {:else if loadFailed} + {:else if loadFailed || projectUnavailable}
{m.chat_slash_command_load_failed()}
diff --git a/web/src/lib/components/chat/__tests__/ChatSurface.test.ts b/web/src/lib/components/chat/__tests__/ChatSurface.test.ts index 6973b1004..43baf7a14 100644 --- a/web/src/lib/components/chat/__tests__/ChatSurface.test.ts +++ b/web/src/lib/components/chat/__tests__/ChatSurface.test.ts @@ -38,8 +38,6 @@ function chat(): ChatSessionRecord { id: 'chat-1', parentChat: null, projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', diff --git a/web/src/lib/components/chat/__tests__/ConversationPanel.test.ts b/web/src/lib/components/chat/__tests__/ConversationPanel.test.ts index 74eab66c8..0054041b3 100644 --- a/web/src/lib/components/chat/__tests__/ConversationPanel.test.ts +++ b/web/src/lib/components/chat/__tests__/ConversationPanel.test.ts @@ -66,8 +66,6 @@ function chat(): ChatSessionRecord { id: 'chat-1', parentChat: null, projectPath: '/project', - effectiveProjectKey: '/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', @@ -190,7 +188,7 @@ function makeActions(): ConversationPanelActions { deleteQueue: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), openCommit: vi.fn(), - toggleBranch: vi.fn(), + toggleBranch: vi.fn().mockResolvedValue(undefined), closeBranch: vi.fn(), createBranch: vi.fn(), switchBranch: vi.fn().mockResolvedValue(undefined), diff --git a/web/src/lib/components/chat/__tests__/ConversationWorkspace.escape.test.ts b/web/src/lib/components/chat/__tests__/ConversationWorkspace.escape.test.ts index 5f919469c..cd117cc11 100644 --- a/web/src/lib/components/chat/__tests__/ConversationWorkspace.escape.test.ts +++ b/web/src/lib/components/chat/__tests__/ConversationWorkspace.escape.test.ts @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ConversationWorkspaceEscapeHost from './ConversationWorkspaceEscapeHost.svelte'; import { getChatExecutionControl, getChatMessages, stopChat } from '$lib/api/chats.js'; +import { getGitRefs } from '$lib/api/git.js'; import { ToolResultMessage } from '$shared/chat-types'; import type { TranscriptMessage } from '$shared/chat-view'; +import type { ProjectResolutionResponse, ProjectTarget } from '$shared/project-resolution'; type BackgroundMessagesHandler = ( chatId: string, @@ -42,6 +44,11 @@ vi.mock('$lib/api/chats.js', () => ({ updateExecutionSettings: vi.fn(), })); +vi.mock('$lib/api/git.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getGitRefs: vi.fn() }; +}); + vi.mock('$lib/chat/conversation/conversation-router-adapter.svelte.js', () => ({ mountConversationRouter: vi.fn(), })); @@ -79,6 +86,7 @@ vi.mock('$lib/components/chat/QueuedInputsDialog.svelte', async () => ({ const mockGetChatMessages = vi.mocked(getChatMessages); const mockGetChatExecutionControl = vi.mocked(getChatExecutionControl); const mockStopChat = vi.mocked(stopChat); +const mockGetGitRefs = vi.mocked(getGitRefs); describe('ConversationWorkspace Escape abort handling', () => { beforeEach(() => { @@ -132,6 +140,8 @@ describe('ConversationWorkspace Escape abort handling', () => { updatedAt: null, }, }); + mockGetGitRefs.mockReset(); + mockGetGitRefs.mockResolvedValue({ refs: [] }); }); it('patches activity for a tool-only background reconnect batch', () => { @@ -158,6 +168,99 @@ describe('ConversationWorkspace Escape abort handling', () => { expect(patchActivity).toHaveBeenCalledWith('chat-background', timestamp); }); + it('does not publish branch state after the initiating surface loses command ownership', async () => { + let resolveProject!: (value: ProjectResolutionResponse) => void; + let target!: ProjectTarget; + const fetchProjectResolution = vi.fn((requestedTarget: ProjectTarget) => { + target = requestedTarget; + return new Promise((resolve) => { + resolveProject = resolve; + }); + }); + const { component } = render(ConversationWorkspaceEscapeHost, { fetchProjectResolution }); + + await fireEvent.click(screen.getByRole('button', { name: 'Open branch dropdown' })); + await waitFor(() => expect(fetchProjectResolution).toHaveBeenCalledOnce()); + await fireEvent.click(screen.getByRole('button', { name: 'Move command ownership' })); + resolveProject({ + target, + resolution: { kind: 'available', effectiveProjectKey: target.projectPath }, + }); + await component.waitForBranchAction(); + + expect(screen.getByTestId('branch-dropdown-open').textContent).toBe('false'); + expect(mockGetGitRefs).not.toHaveBeenCalled(); + }); + + it('does not resurrect a branch action after command ownership leaves and returns', async () => { + let resolveProject!: (value: ProjectResolutionResponse) => void; + let target!: ProjectTarget; + const fetchProjectResolution = vi.fn((requestedTarget: ProjectTarget) => { + target = requestedTarget; + return new Promise((resolve) => { + resolveProject = resolve; + }); + }); + const { component } = render(ConversationWorkspaceEscapeHost, { fetchProjectResolution }); + + await fireEvent.click(screen.getByRole('button', { name: 'Open branch dropdown' })); + await waitFor(() => expect(fetchProjectResolution).toHaveBeenCalledOnce()); + await fireEvent.click(screen.getByRole('button', { name: 'Move command ownership' })); + await fireEvent.click(screen.getByRole('button', { name: 'Restore command ownership' })); + resolveProject({ + target, + resolution: { kind: 'available', effectiveProjectKey: target.projectPath }, + }); + await component.waitForBranchAction(); + + expect(screen.getByTestId('branch-dropdown-open').textContent).toBe('false'); + expect(mockGetGitRefs).not.toHaveBeenCalled(); + }); + + it('keeps a branch action owned when focus repeats within the same surface', async () => { + let resolveProject!: (value: ProjectResolutionResponse) => void; + let target!: ProjectTarget; + const fetchProjectResolution = vi.fn((requestedTarget: ProjectTarget) => { + target = requestedTarget; + return new Promise((resolve) => { + resolveProject = resolve; + }); + }); + render(ConversationWorkspaceEscapeHost, { fetchProjectResolution }); + + await fireEvent.click(screen.getByRole('button', { name: 'Open branch dropdown' })); + await waitFor(() => expect(fetchProjectResolution).toHaveBeenCalledOnce()); + await fireEvent.click(screen.getByRole('button', { name: 'Refocus command surface' })); + resolveProject({ + target, + resolution: { kind: 'available', effectiveProjectKey: target.projectPath }, + }); + + await waitFor(() => + expect(screen.getByTestId('branch-dropdown-open').textContent).toBe('true'), + ); + expect(mockGetGitRefs).toHaveBeenCalledOnce(); + }); + + it('opens create branch after the selector closes its branch dropdown', async () => { + const fetchProjectResolution = vi.fn(async (target: ProjectTarget) => ({ + target, + resolution: { kind: 'available' as const, effectiveProjectKey: target.projectPath }, + })); + render(ConversationWorkspaceEscapeHost, { fetchProjectResolution }); + + await fireEvent.click(screen.getByRole('button', { name: 'Open branch dropdown' })); + await waitFor(() => + expect(screen.getByTestId('branch-dropdown-open').textContent).toBe('true'), + ); + await fireEvent.click(screen.getByRole('button', { name: 'Create new branch' })); + + await waitFor(() => + expect(screen.getByTestId('new-branch-dialog-open').textContent).toBe('true'), + ); + expect(fetchProjectResolution).toHaveBeenCalledTimes(2); + }); + afterEach(() => { cleanup(); document.body.innerHTML = ''; diff --git a/web/src/lib/components/chat/__tests__/ConversationWorkspaceEscapeHost.svelte b/web/src/lib/components/chat/__tests__/ConversationWorkspaceEscapeHost.svelte index 083f8a63e..29ceb437b 100644 --- a/web/src/lib/components/chat/__tests__/ConversationWorkspaceEscapeHost.svelte +++ b/web/src/lib/components/chat/__tests__/ConversationWorkspaceEscapeHost.svelte @@ -1,4 +1,5 @@ @@ -290,6 +337,36 @@ }}>Toggle processing + + + + +
{quickGitBranches.showBranchDropdown}
+
{quickGitBranches.showNewBranchModal}
+ panelActions?.closeBranch(CANONICAL_CHAT_SURFACE_ID, selectedChat.id)} + onCreateBranch={() => panelActions?.createBranch(CANONICAL_CHAT_SURFACE_ID, selectedChat.id)} + onSwitchBranch={(branch) => + panelActions?.switchBranch(CANONICAL_CHAT_SURFACE_ID, selectedChat.id, branch)} + onSortRefs={(key, query) => + panelActions?.sortBranches(CANONICAL_CHAT_SURFACE_ID, selectedChat.id, key, query)} +/> {#if showTestLayer}
{/if} - + (panelActions = actions)} +/> ({ getFileList: vi.fn(), })); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe('FileMentionMenu', () => { + beforeEach(() => { + vi.mocked(getFileList).mockReset(); + }); + it('shows project-relative files and excludes directory entries', async () => { vi.mocked(getFileList).mockResolvedValue([ { name: 'src', path: '/repo/src', type: 'directory' }, @@ -55,4 +68,53 @@ describe('FileMentionMenu', () => { expect(onSelect).toHaveBeenCalledWith('b.ts'); }); + + it('restarts a file request aborted by a pending project transition', async () => { + const first = deferred>>(); + vi.mocked(getFileList) + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce([ + { name: 'recovered.ts', path: '/repo/recovered.ts', relativePath: 'recovered.ts' }, + ]); + const view = render(FileMentionMenuTestHost, { + projectPath: '/repo', + isVisible: true, + query: '', + onSelect: vi.fn(), + onClose: vi.fn(), + }); + await waitFor(() => expect(getFileList).toHaveBeenCalledOnce()); + const firstSignal = vi.mocked(getFileList).mock.calls[0]?.[1]?.signal; + + await view.rerender({ projectPath: '', projectPending: true }); + expect(firstSignal?.aborted).toBe(true); + await view.rerender({ projectPath: '/repo', projectPending: false }); + + await waitFor(() => expect(getFileList).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('recovered.ts')).toBeTruthy(); + first.resolve([]); + await tick(); + expect(screen.getByText('recovered.ts')).toBeTruthy(); + }); + + it('blocks cached file selection while the project is unavailable', async () => { + vi.mocked(getFileList).mockResolvedValue([ + { name: 'cached.ts', path: '/repo/cached.ts', relativePath: 'cached.ts' }, + ]); + const onSelect = vi.fn(); + const view = render(FileMentionMenuTestHost, { + projectPath: '/repo', + isVisible: true, + query: '', + onSelect, + onClose: vi.fn(), + }); + await screen.findByText('cached.ts'); + + await view.rerender({ projectUnavailable: true }); + view.component.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(screen.queryByText('cached.ts')).toBeNull(); + expect(onSelect).not.toHaveBeenCalled(); + }); }); diff --git a/web/src/lib/components/chat/__tests__/PromptComposer.test.ts b/web/src/lib/components/chat/__tests__/PromptComposer.test.ts index 6b75ac9be..5c8592ea1 100644 --- a/web/src/lib/components/chat/__tests__/PromptComposer.test.ts +++ b/web/src/lib/components/chat/__tests__/PromptComposer.test.ts @@ -11,6 +11,7 @@ import { ImageAttachmentState } from '$lib/chat/composer/image-attachment.svelte import { chatDraftStorageKey, LOCAL_STORAGE_KEYS } from '$lib/utils/local-persistence.js'; import * as snippetsApi from '$lib/api/snippets'; import { PromptComposerHeightState } from '../prompt-composer-height-state.svelte.js'; +import type { ProjectResolutionResponse, ProjectTarget } from '$shared/project-resolution'; const appCss = readFileSync('src/app.css', 'utf8'); @@ -1102,6 +1103,22 @@ describe('PromptComposer focus', () => { expect(screen.queryByText('/fork')).toBeNull(); }); + it('keeps completion project resolution stable across selected chat record updates', async () => { + const { component, rerender } = render(PromptComposerTestHost, { + selectedChatId: 'chat-completion-resolution', + selectedStatus: 'running', + selectedIsProcessing: false, + }); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + await fireEvent.input(textarea, { target: { value: '/' } }); + await waitFor(() => expect(component.getProjectResolutionRequestCount()).toBe(1)); + + await rerender({ selectedIsProcessing: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(component.getProjectResolutionRequestCount()).toBe(1); + }); + it('offers /in only for an existing chat', async () => { const { unmount } = render(PromptComposerTestHost, { selectedChatId: 'chat-1', @@ -1287,6 +1304,41 @@ describe('PromptComposer focus', () => { expect(textarea.value).toBe('/snippet review cancellable'); }); + it('lets Escape cancel project resolution before snippet expansion starts', async () => { + const pending = deferredProjectResolution(); + let signal!: AbortSignal; + let requestedTarget!: ProjectTarget; + const fetchProjectResolution = vi.fn((target: ProjectTarget, requestSignal: AbortSignal) => { + signal = requestSignal; + requestedTarget = target; + return pending.promise; + }); + render(PromptComposerTestHost, { + selectedChatId: 'chat-snippet-resolution-cancel', + selectedStatus: 'running', + fetchProjectResolution, + }); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + await fireEvent.input(textarea, { target: { value: '/snippet review cancellable' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + await screen.findByRole('button', { name: 'Expanding snippet' }); + expect(snippetsApi.expandSnippet).not.toHaveBeenCalled(); + + await fireEvent.keyDown(textarea, { key: 'Escape' }); + expect(signal.aborted).toBe(true); + pending.resolve({ + target: requestedTarget, + resolution: { kind: 'available', effectiveProjectKey: requestedTarget.projectPath }, + }); + await pending.promise; + await waitFor(() => + expect(screen.getByRole('button', { name: 'Send message' })).toBeTruthy(), + ); + + expect(snippetsApi.expandSnippet).not.toHaveBeenCalled(); + expect(textarea.value).toBe('/snippet review cancellable'); + }); + it('lets another composer control cancel a pending expansion with Escape', async () => { const pending = deferredSnippetExpansion(); vi.mocked(snippetsApi.expandSnippet).mockReturnValueOnce(pending.promise); @@ -1414,6 +1466,7 @@ describe('PromptComposer focus', () => { await fireEvent.click(await screen.findByRole('menuitem', { name: /Snippets/ })); await fireEvent.click(await screen.findByRole('option', { name: /^review/ })); await screen.findByRole('button', { name: 'Expanding snippet' }); + await waitFor(() => expect(snippetsApi.expandSnippet).toHaveBeenCalledOnce()); const attachment = new File(['image'], 'dropped.png', { type: 'image/png' }); const dropTarget = textarea.closest('[role="region"]'); @@ -1763,6 +1816,52 @@ describe('PromptComposer focus', () => { expect(textarea.value).toBe('Keep this draft'); }); + it('rejects a slash expansion resolved for another project path', async () => { + vi.mocked(snippetsApi.expandSnippet).mockResolvedValueOnce({ + success: true, + snippetId: 'snippet-review', + snippetUpdatedAt: '2026-01-01T00:00:00.000Z', + shortName: 'review', + contextProjectPath: '/workspace/two', + expandedText: 'must not apply', + }); + render(PromptComposerTestHost, { + selectedChatId: 'chat-snippet-response-path', + selectedStatus: 'running', + projectPath: '/workspace/one', + }); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + await fireEvent.input(textarea, { target: { value: '/snippet review keep this' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Send message' })).toBeTruthy(), + ); + expect(textarea.value).toBe('/snippet review keep this'); + }); + + it('presents unavailable project recovery actions for completion demand', async () => { + const fetchProjectResolution = vi.fn(async (target: ProjectTarget) => ({ + target, + resolution: { kind: 'unavailable' as const, reason: 'not-found' as const }, + })); + const onChooseProjectFolder = vi.fn(); + render(PromptComposerTestHost, { + selectedChatId: 'chat-project-unavailable', + selectedStatus: 'running', + fetchProjectResolution, + onChooseProjectFolder, + }); + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement; + await fireEvent.input(textarea, { target: { value: '/' } }); + + await screen.findByText('Project folder unavailable'); + await fireEvent.click(screen.getByRole('button', { name: 'Choose folder' })); + expect(onChooseProjectFolder).toHaveBeenCalledWith('chat-project-unavailable'); + await fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(fetchProjectResolution).toHaveBeenCalledTimes(2)); + }); + it('reports a missing project path instead of swallowing a snippet command', async () => { render(PromptComposerTestHost, { selectedChatId: 'chat-snippet-missing-path', @@ -1774,7 +1873,7 @@ describe('PromptComposer focus', () => { await fireEvent.keyDown(textarea, { key: 'Enter' }); - await screen.findByText('Project path is required.'); + await screen.findByText('Snippet expansion failed: Project path is required.'); expect(snippetsApi.expandSnippet).not.toHaveBeenCalled(); expect(textarea.value).toBe('/snippet review this'); }); @@ -1850,3 +1949,14 @@ function deferredSnippetExpansion() { }); return { promise, resolve }; } + +function deferredProjectResolution() { + let resolve!: (value: ProjectResolutionResponse) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { + promise, + resolve, + }; +} diff --git a/web/src/lib/components/chat/__tests__/PromptComposerTestHost.svelte b/web/src/lib/components/chat/__tests__/PromptComposerTestHost.svelte index 1651f546b..13ca6732e 100644 --- a/web/src/lib/components/chat/__tests__/PromptComposerTestHost.svelte +++ b/web/src/lib/components/chat/__tests__/PromptComposerTestHost.svelte @@ -10,6 +10,7 @@ setLocalSettings, setModelCatalog, setNotifications, + setProjectResolution, setRemoteSettings, setSnippets, setTransientLayers, @@ -44,6 +45,8 @@ } from '$lib/workspace/workspace-shortcuts.js'; import { CANONICAL_CHAT_SURFACE_ID } from '$lib/workspace/canonical-layout.js'; import { setCanonicalWorkspaceLayout } from './workspace-layout-test-context.js'; + import { ProjectResolutionStore } from '$lib/workspace/project-resolution-store.svelte.js'; + import type { ProjectTarget } from '$shared/project-resolution'; interface Props { selectedChatId?: string; @@ -70,10 +73,12 @@ quickCommitSummary?: GitQuickSummaryReady | null; directAdmissionPending?: boolean; requiresQueuedSubmission?: boolean; + fetchProjectResolution?: ConstructorParameters[0]; onsubmit?: () => void; onSteerPreferredSubmit?: () => void; onAbort?: () => void; onQuickCommit?: () => void; + onChooseProjectFolder?: (chatId: string) => void; } let { @@ -101,10 +106,12 @@ quickCommitSummary = null, directAdmissionPending = false, requiresQueuedSubmission: requiresQueuedSubmissionOverride, + fetchProjectResolution, onsubmit = () => {}, onSteerPreferredSubmit = () => {}, onAbort = () => {}, onQuickCommit = () => {}, + onChooseProjectFolder, }: Props = $props(); const chatDrafts = new ChatDraftStore(); @@ -127,6 +134,23 @@ }); const notifications = createNotificationsStore(); let snippetLoadCount = $state(0); + let projectResolutionRequestCount = 0; + export function getProjectResolutionRequestCount(): number { + return projectResolutionRequestCount; + } + function getInitialProjectResolver() { + return ( + fetchProjectResolution ?? + (async (target: ProjectTarget) => { + projectResolutionRequestCount += 1; + return { + target, + resolution: { kind: 'available' as const, effectiveProjectKey: target.projectPath }, + }; + }) + ); + } + const projectResolution = new ProjectResolutionStore(getInitialProjectResolver()); const modelOptionsByAgent: Record = { claude: [{ value: 'opus', label: 'Opus', supportsImages: true }], codex: [{ value: 'gpt-5', label: 'GPT-5', supportsImages: true }], @@ -202,8 +226,6 @@ id: selectedChatId, parentChat: null, projectPath, - effectiveProjectKey: projectPath, - projectIdentityState: 'available', orderGroup: 'normal', title: selectedChatId, agentId: selectedAgentId, @@ -325,6 +347,7 @@ supportsForkWhileRunning: () => true, supportsSteering: (agentId: string) => agentId === 'codex', supportsGoals: (agentId: string) => agentId === 'codex', + supportsUpdateProjectPath: () => true, selectionFor: (_agentId: string, model: string) => ({ model, apiProviderId: null, @@ -351,6 +374,7 @@ applyOptimisticSnapshot: () => () => {}, } as never); setNotifications(notifications); + setProjectResolution(projectResolution); setSnippets( createSnippetsStore({ get: async () => { @@ -377,6 +401,7 @@ onDestroy(() => { unsubscribeSidebarRecenter(); chatDrafts.destroy(); + projectResolution.destroy(); }); const shortcutWorkspace = { focusOwner: { kind: 'surface' as const, surfaceId: CANONICAL_CHAT_SURFACE_ID }, @@ -433,6 +458,7 @@ {composerEditorOpenRequestId} {directAdmissionPending} {requiresQueuedSubmission} + {onChooseProjectFolder} resendCandidates={transcript.resendCandidates} onExcludeResendCandidate={(ordinal) => transcript.excludeResendCandidate(ordinal)} /> diff --git a/web/src/lib/components/chat/__tests__/SlashCommandMenu.test.ts b/web/src/lib/components/chat/__tests__/SlashCommandMenu.test.ts index c0daf7245..57fd1db88 100644 --- a/web/src/lib/components/chat/__tests__/SlashCommandMenu.test.ts +++ b/web/src/lib/components/chat/__tests__/SlashCommandMenu.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { tick } from 'svelte'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('$lib/api/commands.js', () => ({ @@ -20,6 +21,14 @@ const baseProps = { }; const mockedGetSlashCommands = vi.mocked(getSlashCommands); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe('SlashCommandMenu', () => { beforeEach(() => { mockedGetSlashCommands.mockReset(); @@ -416,4 +425,59 @@ describe('SlashCommandMenu', () => { expect(screen.queryByRole('option')).toBeNull(); expect(screen.getByText('No matching commands')).toBeTruthy(); }); + + it('restarts discovery aborted by a pending project transition', async () => { + const first = deferred>>(); + mockedGetSlashCommands + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce([{ name: 'recovered-command', source: 'command' }]); + const view = render(SlashCommandMenuTestHost, { + ...baseProps, + projectPath: '/repo', + isVisible: true, + query: 'recovered-command', + onSelect: vi.fn(), + onClose: vi.fn(), + }); + await waitFor(() => expect(mockedGetSlashCommands).toHaveBeenCalledOnce()); + const firstSignal = mockedGetSlashCommands.mock.calls[0]?.[1]?.signal; + + await view.rerender({ projectPath: '', projectPending: true }); + expect(firstSignal?.aborted).toBe(true); + await view.rerender({ projectPath: '/repo', projectPending: false }); + + await waitFor(() => expect(mockedGetSlashCommands).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('/recovered-command')).toBeTruthy(); + first.resolve([]); + await tick(); + expect(screen.getByText('/recovered-command')).toBeTruthy(); + }); + + it('hides cached discovered commands while keeping built-ins available', async () => { + mockedGetSlashCommands.mockResolvedValue([ + { name: 'agent-command', source: 'command', description: 'Agent command' }, + ]); + const onSelect = vi.fn(); + const view = render(SlashCommandMenuTestHost, { + ...baseProps, + projectPath: '/repo', + isVisible: true, + query: 'agent-command', + onSelect, + onClose: vi.fn(), + }); + await screen.findByText('/agent-command'); + + await view.rerender({ projectUnavailable: true }); + expect(view.component.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))).toBe( + false, + ); + expect(screen.queryByText('/agent-command')).toBeNull(); + expect(onSelect).not.toHaveBeenCalled(); + + await view.rerender({ query: 'compact' }); + expect(screen.getByText('/compact')).toBeTruthy(); + expect(view.component.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))).toBe(true); + expect(onSelect).toHaveBeenCalledWith('compact'); + }); }); diff --git a/web/src/lib/components/chat/__tests__/chat-action-controller.test.ts b/web/src/lib/components/chat/__tests__/chat-action-controller.test.ts index 07adfd207..03f14c5c4 100644 --- a/web/src/lib/components/chat/__tests__/chat-action-controller.test.ts +++ b/web/src/lib/components/chat/__tests__/chat-action-controller.test.ts @@ -28,8 +28,6 @@ function makeChat(overrides: Partial = {}): ChatSessionRecord return { id: 'chat-1', projectPath: '/workspace/repo', - effectiveProjectKey: '/workspace/repo', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', @@ -64,7 +62,6 @@ function makeServerChat(overrides: Partial = {}): ChatListEntry { agentSettings: { ownerId: 'claude', schemaVersion: 1, values: {} }, title: 'Fork', projectPath: '/workspace/repo', - effectiveProjectKey: '/workspace/repo', orderGroup: 'normal', tags: [], activity: { createdAt: null, lastActivityAt: null, lastReadAt: null }, @@ -84,10 +81,12 @@ function makeServerChat(overrides: Partial = {}): ChatListEntry { function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; + reject = rejectPromise; }); - return { promise, resolve }; + return { promise, resolve, reject }; } function createHarness( @@ -101,6 +100,7 @@ function createHarness( let selectedChatId = options.selectedChatId === undefined ? (chats[0]?.id ?? null) : options.selectedChatId; const callbacks = { + projectPathRevision: vi.fn(() => 0), onQuietRefresh: vi.fn(async () => undefined), onSelectChat: vi.fn(), onNewChat: vi.fn(), @@ -143,6 +143,7 @@ function createHarness( return { controller: new ChatActionController(deps), callbacks, + chats, setSelectedChatId(chatId: string | null) { selectedChatId = chatId; }, @@ -338,14 +339,13 @@ describe('ChatActionController', () => { }); }); - it('updates tags and publishes the normalized project identity returned by the server', async () => { + it('updates tags and publishes the normalized project path returned by the server', async () => { vi.mocked(chatsApi.updateChatProjectPath).mockResolvedValueOnce({ success: true, chatId: 'chat-1', projectPath: '/workspace/canonical', effectiveProjectKey: '/workspace/canonical', previousProjectPath: '/workspace/repo', - previousEffectiveProjectKey: '/workspace/repo', }); const { controller, callbacks } = createHarness(); @@ -360,10 +360,116 @@ describe('ChatActionController', () => { }); expect(callbacks.onProjectPathUpdated).toHaveBeenCalledWith('chat-1', { projectPath: '/workspace/canonical', - effectiveProjectKey: '/workspace/canonical', }); }); + it('does not apply a PATCH result after a newer WebSocket path', async () => { + const pending = deferred>>(); + vi.mocked(chatsApi.updateChatProjectPath).mockReturnValueOnce(pending.promise); + const { controller, callbacks, chats } = createHarness(); + const update = controller.updateProjectPath('chat-1', '/workspace/requested'); + chats[0] = makeChat({ projectPath: '/workspace/newer' }); + pending.resolve({ + success: true, + chatId: 'chat-1', + projectPath: '/workspace/requested', + effectiveProjectKey: '/workspace/requested', + previousProjectPath: '/workspace/repo', + }); + + await update; + + expect(callbacks.onProjectPathUpdated).not.toHaveBeenCalled(); + }); + + it('applies a PATCH result idempotently after the same WebSocket path', async () => { + const pending = deferred>>(); + vi.mocked(chatsApi.updateChatProjectPath).mockReturnValueOnce(pending.promise); + const { controller, callbacks, chats } = createHarness(); + const update = controller.updateProjectPath('chat-1', '/workspace/requested'); + chats[0] = makeChat({ projectPath: '/workspace/requested' }); + pending.resolve({ + success: true, + chatId: 'chat-1', + projectPath: '/workspace/requested', + effectiveProjectKey: '/workspace/requested', + previousProjectPath: '/workspace/repo', + }); + + await update; + + expect(callbacks.onProjectPathUpdated).toHaveBeenCalledWith('chat-1', { + projectPath: '/workspace/requested', + }); + }); + + it('does not apply a PATCH result after an observed A/B/A binding sequence', async () => { + const pending = deferred>>(); + vi.mocked(chatsApi.updateChatProjectPath).mockReturnValueOnce(pending.promise); + const { controller, callbacks, chats } = createHarness(); + const update = controller.updateProjectPath('chat-1', '/workspace/requested'); + chats[0] = makeChat({ projectPath: '/workspace/temporary' }); + callbacks.projectPathRevision.mockReturnValue(1); + chats[0] = makeChat({ projectPath: '/workspace/repo' }); + callbacks.projectPathRevision.mockReturnValue(2); + pending.resolve({ + success: true, + chatId: 'chat-1', + projectPath: '/workspace/requested', + effectiveProjectKey: '/workspace/requested', + previousProjectPath: '/workspace/repo', + }); + + await update; + + expect(callbacks.onProjectPathUpdated).not.toHaveBeenCalled(); + }); + + it('lets a second project-path request supersede the first', async () => { + const first = deferred>>(); + const second = deferred>>(); + vi.mocked(chatsApi.updateChatProjectPath) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const { controller, callbacks } = createHarness(); + const firstUpdate = controller.updateProjectPath('chat-1', '/workspace/first'); + const secondUpdate = controller.updateProjectPath('chat-1', '/workspace/second'); + second.resolve({ + success: true, + chatId: 'chat-1', + projectPath: '/workspace/second', + effectiveProjectKey: '/workspace/second', + previousProjectPath: '/workspace/repo', + }); + await secondUpdate; + first.resolve({ + success: true, + chatId: 'chat-1', + projectPath: '/workspace/first', + effectiveProjectKey: '/workspace/first', + previousProjectPath: '/workspace/repo', + }); + await firstUpdate; + + expect(callbacks.onProjectPathUpdated).toHaveBeenCalledOnce(); + expect(callbacks.onProjectPathUpdated).toHaveBeenCalledWith('chat-1', { + projectPath: '/workspace/second', + }); + }); + + it('rejects project-path updates without a current binding', async () => { + const missing = createHarness({ chats: [] }); + const empty = createHarness({ chats: [makeChat({ projectPath: '' })] }); + + await expect(missing.controller.updateProjectPath('chat-1', '/workspace/new')).rejects.toThrow( + m.sidebar_project_path_errors_update_failed(), + ); + await expect(empty.controller.updateProjectPath('chat-1', '/workspace/new')).rejects.toThrow( + m.sidebar_project_path_errors_update_failed(), + ); + expect(chatsApi.updateChatProjectPath).not.toHaveBeenCalled(); + }); + it('upserts and selects a server-confirmed fork', async () => { const fork = makeServerChat(); vi.mocked(chatsApi.forkChat).mockResolvedValueOnce({ success: true, chat: fork }); diff --git a/web/src/lib/components/chat/__tests__/chat-action-dialogs-state.test.ts b/web/src/lib/components/chat/__tests__/chat-action-dialogs-state.test.ts index 74b411d14..457514463 100644 --- a/web/src/lib/components/chat/__tests__/chat-action-dialogs-state.test.ts +++ b/web/src/lib/components/chat/__tests__/chat-action-dialogs-state.test.ts @@ -6,8 +6,6 @@ function makeChat(overrides: Partial = {}): ChatSessionRecord return { id: 'chat-1', projectPath: '/tmp/project', - effectiveProjectKey: '/tmp/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', diff --git a/web/src/lib/components/chat/__tests__/chat-surface-presentation.logic.test.ts b/web/src/lib/components/chat/__tests__/chat-surface-presentation.logic.test.ts index 157138eec..346ef900c 100644 --- a/web/src/lib/components/chat/__tests__/chat-surface-presentation.logic.test.ts +++ b/web/src/lib/components/chat/__tests__/chat-surface-presentation.logic.test.ts @@ -1,31 +1,51 @@ import { describe, expect, it } from 'vitest'; import { resolveChatSurfacePresentation } from '../chat-surface-presentation'; +import type { ChatSessionRecord } from '$lib/types/chat-session'; + +function chat(status: ChatSessionRecord['status']): ChatSessionRecord { + return { + id: 'chat-1', + parentChat: null, + projectPath: '/workspace/project', + orderGroup: 'normal', + title: 'Chat', + agentId: 'claude', + agentOwnershipEpoch: null, + model: 'sonnet', + permissionMode: 'default', + thinkingMode: 'none', + agentSettings: { ownerId: 'claude', schemaVersion: 1, values: {} }, + createdAt: null, + lastActivityAt: null, + lastReadAt: null, + isPinned: false, + isArchived: false, + isProcessing: false, + processingPhase: null, + isUnread: false, + canReloadFromNativeHistory: false, + status, + tags: [], + }; +} describe('resolveChatSurfacePresentation', () => { it('keeps a pending draft renderable so startup errors and retry input stay visible', () => { expect( resolveChatSurfacePresentation( - { - status: 'draft', - projectIdentityState: 'pending', - effectiveProjectKey: null, - }, + chat('draft'), false, ), ).toBe('conversation'); }); - it('loads unresolved running chats until their project identity is available', () => { + it('renders running chats without requiring project resolution', () => { expect( resolveChatSurfacePresentation( - { - status: 'running', - projectIdentityState: 'pending', - effectiveProjectKey: null, - }, + chat('running'), false, ), - ).toBe('loading'); + ).toBe('conversation'); }); it('distinguishes an empty chat list from one that is still loading', () => { diff --git a/web/src/lib/components/chat/__tests__/prompt-composer-project-state.test.ts b/web/src/lib/components/chat/__tests__/prompt-composer-project-state.test.ts new file mode 100644 index 000000000..ae9b1f3f4 --- /dev/null +++ b/web/src/lib/components/chat/__tests__/prompt-composer-project-state.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ChatSessionRecord } from '$lib/types/chat-session'; +import { ProjectResolutionStore } from '$lib/workspace/project-resolution-store.svelte.js'; +import { PromptComposerProjectState } from '../prompt-composer-project-state.svelte.js'; + +function chat(overrides: Partial = {}): ChatSessionRecord { + return { + id: 'chat-1', + parentChat: null, + projectPath: '/project-a', + orderGroup: 'normal', + title: 'Chat', + agentId: 'claude', + model: 'sonnet', + permissionMode: 'default', + thinkingMode: 'none', + agentSettings: { ownerId: 'claude', schemaVersion: 1, values: {} }, + createdAt: null, + lastActivityAt: null, + lastReadAt: null, + isPinned: false, + isArchived: false, + isProcessing: false, + processingPhase: null, + canReloadFromNativeHistory: false, + isUnread: false, + status: 'running', + agentOwnershipEpoch: 'epoch-1', + tags: [], + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +describe('PromptComposerProjectState', () => { + it('reports a binding change before classifying its obsolete resolution', async () => { + const request = deferred<{ + target: { kind: 'chat'; chatId: string; projectPath: string }; + resolution: { kind: 'unavailable'; reason: 'not-found' }; + }>(); + const fetchResolution = vi.fn(() => request.promise); + const projectResolution = new ProjectResolutionStore(fetchResolution); + let selectedChat = chat(); + const projectState = new PromptComposerProjectState({ + get selectedChat() { + return selectedChat; + }, + completionDemand: false, + projectResolution, + }); + + const resolving = projectState.resolveSnippetContext(); + selectedChat = chat({ projectPath: '/project-b' }); + projectResolution.markObsoleteChatTargets('chat-1', '/project-b'); + request.resolve({ + target: { kind: 'chat', chatId: 'chat-1', projectPath: '/project-a' }, + resolution: { kind: 'unavailable', reason: 'not-found' }, + }); + + await expect(resolving).rejects.toThrow('The chat project changed.'); + projectState.destroy(); + projectResolution.destroy(); + }); +}); diff --git a/web/src/lib/components/chat/chat-action-controller.svelte.ts b/web/src/lib/components/chat/chat-action-controller.svelte.ts index 02bbcae9c..96b849410 100644 --- a/web/src/lib/components/chat/chat-action-controller.svelte.ts +++ b/web/src/lib/components/chat/chat-action-controller.svelte.ts @@ -8,6 +8,7 @@ import type { ChatListEntry } from '$shared/chat-list'; export interface ChatActionControllerDeps { get chats(): ChatSessionRecord[]; get selectedChatId(): string | null; + projectPathRevision: (chatId: string) => number; onQuietRefresh: () => Promise | void; isArchiveMutationPending: (chatId: string) => boolean; startArchivingChats: (chatIds: readonly string[]) => ChatArchiveMutation; @@ -16,10 +17,7 @@ export interface ChatActionControllerDeps { onNewChat: () => void; onDeleteChat: (chatId: string) => Promise | void; onRenameChat: (chatId: string, newTitle: string) => Promise | void; - onProjectPathUpdated: ( - chatId: string, - patch: { projectPath: string; effectiveProjectKey: string }, - ) => void; + onProjectPathUpdated: (chatId: string, patch: { projectPath: string }) => void; onUpsertServerChat: (entry: ChatListEntry) => void; onReloadChat?: (chatId: string) => Promise | void; notifyError: (message: string) => void; @@ -29,6 +27,7 @@ export interface ChatActionControllerDeps { export class ChatActionController { #sidebarController: SidebarController; + #projectPathRequestGeneration = new Map(); constructor(private readonly deps: ChatActionControllerDeps) { this.#sidebarController = new SidebarController({ @@ -144,10 +143,22 @@ export class ChatActionController { } async updateProjectPath(chatId: string, projectPath: string): Promise { + const expectedProjectPath = this.deps.chats.find((entry) => entry.id === chatId)?.projectPath; + if (!expectedProjectPath) throw new Error(m.sidebar_project_path_errors_update_failed()); + const expectedRevision = this.deps.projectPathRevision(chatId); + const generation = (this.#projectPathRequestGeneration.get(chatId) ?? 0) + 1; + this.#projectPathRequestGeneration.set(chatId, generation); const result = await this.#sidebarController.updateProjectPath(chatId, projectPath); + if (this.#projectPathRequestGeneration.get(chatId) !== generation) return; + const currentProjectPath = this.deps.chats.find((entry) => entry.id === chatId)?.projectPath; + if ( + currentProjectPath !== result.projectPath && + (currentProjectPath !== expectedProjectPath || + this.deps.projectPathRevision(chatId) !== expectedRevision) + ) + return; this.deps.onProjectPathUpdated(chatId, { projectPath: result.projectPath, - effectiveProjectKey: result.effectiveProjectKey, }); } diff --git a/web/src/lib/components/chat/chat-surface-presentation.ts b/web/src/lib/components/chat/chat-surface-presentation.ts index 47d3c5214..b1813987a 100644 --- a/web/src/lib/components/chat/chat-surface-presentation.ts +++ b/web/src/lib/components/chat/chat-surface-presentation.ts @@ -3,15 +3,8 @@ import type { ChatSessionRecord } from '$lib/types/chat-session.js'; export type ChatSurfacePresentation = 'conversation' | 'loading' | 'empty'; export function resolveChatSurfacePresentation( - selectedChat: Pick< - ChatSessionRecord, - 'status' | 'projectIdentityState' | 'effectiveProjectKey' - > | null, + selectedChat: ChatSessionRecord | null, isLoadingChats: boolean, ): ChatSurfacePresentation { - if (!selectedChat) return isLoadingChats ? 'loading' : 'empty'; - if (selectedChat.status === 'draft') return 'conversation'; - return selectedChat.projectIdentityState === 'available' && selectedChat.effectiveProjectKey - ? 'conversation' - : 'loading'; + return selectedChat ? 'conversation' : isLoadingChats ? 'loading' : 'empty'; } diff --git a/web/src/lib/components/chat/conversation-panel-actions.ts b/web/src/lib/components/chat/conversation-panel-actions.ts index bf2a1ebfa..785e9d105 100644 --- a/web/src/lib/components/chat/conversation-panel-actions.ts +++ b/web/src/lib/components/chat/conversation-panel-actions.ts @@ -53,7 +53,7 @@ export interface ConversationPanelActions { deleteQueue(surfaceId: ChatViewSurfaceId, chatId: string, entryId: string): Promise; stop(surfaceId: ChatViewSurfaceId, chatId: string): Promise; openCommit(surfaceId: ChatViewSurfaceId, chatId: string): void; - toggleBranch(surfaceId: ChatViewSurfaceId, chatId: string): void; + toggleBranch(surfaceId: ChatViewSurfaceId, chatId: string): Promise; closeBranch(surfaceId: ChatViewSurfaceId, chatId: string): void; createBranch(surfaceId: ChatViewSurfaceId, chatId: string): void; switchBranch(surfaceId: ChatViewSurfaceId, chatId: string, branch: string): Promise; diff --git a/web/src/lib/components/chat/prompt-composer-project-state.svelte.ts b/web/src/lib/components/chat/prompt-composer-project-state.svelte.ts new file mode 100644 index 000000000..cca47ddbd --- /dev/null +++ b/web/src/lib/components/chat/prompt-composer-project-state.svelte.ts @@ -0,0 +1,129 @@ +import { untrack } from 'svelte'; +import type { ChatSessionRecord } from '$lib/types/chat-session'; +import type { SnippetExpansionContext } from '$shared/snippets'; +import type { ProjectTarget } from '$shared/project-resolution'; +import type { + ProjectResolutionSnapshot, + ProjectResolutionStore, +} from '$lib/workspace/project-resolution-store.svelte.js'; +import * as m from '$lib/paraglide/messages.js'; + +interface PromptComposerProjectStateDeps { + readonly selectedChat: ChatSessionRecord | null; + readonly completionDemand: boolean; + projectResolution: ProjectResolutionStore; +} + +export interface PromptComposerSnippetContext { + context: SnippetExpansionContext; + chatId: string; + projectPath: string; +} + +export class PromptComposerProjectState { + readonly #destroyEffects: () => void; + + constructor(private readonly deps: PromptComposerProjectStateDeps) { + this.#destroyEffects = $effect.root(() => { + const targetKey = $derived.by(() => { + const target = this.target; + return target ? this.deps.projectResolution.lifecycleKey(target) : null; + }); + $effect(() => { + if (!targetKey || !this.deps.completionDemand) return; + const lease = untrack(() => { + const target = this.target; + if (!target) return null; + const retained = this.deps.projectResolution.retain(target); + void retained.resolve(); + return retained; + }); + return lease ? () => untrack(() => lease.release()) : undefined; + }); + }); + } + + get target(): ProjectTarget | null { + const chat = this.deps.selectedChat; + if (!chat?.projectPath) return null; + return chat.status === 'draft' + ? { kind: 'path', projectPath: chat.projectPath } + : { kind: 'chat', chatId: chat.id, projectPath: chat.projectPath }; + } + + get snapshot(): ProjectResolutionSnapshot { + const target = this.target; + return target ? this.deps.projectResolution.snapshotFor(target) : { kind: 'unchecked' }; + } + + get completionProjectPath(): string { + return this.snapshot.kind === 'available' ? (this.target?.projectPath ?? '') : ''; + } + + retry(): void { + const target = this.target; + if (!target) return; + const lease = this.deps.projectResolution.retain(target); + void lease.retry().finally(() => lease.release()); + } + + async resolveSnippetContext(signal?: AbortSignal): Promise { + const chat = this.deps.selectedChat; + const projectPath = chat?.projectPath.trim(); + if (!chat || !projectPath) throw new Error(m.chat_new_chat_errors_project_path_required()); + signal?.throwIfAborted(); + const target: ProjectTarget = + chat.status === 'draft' + ? { kind: 'path', projectPath } + : { kind: 'chat', chatId: chat.id, projectPath }; + const lease = this.deps.projectResolution.retain(target); + let released = false; + const release = () => { + if (released) return; + released = true; + lease.release(); + }; + let rejectAbort: ((reason: unknown) => void) | null = null; + const aborted = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); + const releaseOnAbort = () => { + release(); + rejectAbort?.(signal?.reason ?? new DOMException('Aborted', 'AbortError')); + }; + signal?.addEventListener('abort', releaseOnAbort, { once: true }); + try { + if (signal?.aborted) releaseOnAbort(); + await (signal ? Promise.race([lease.resolve(), aborted]) : lease.resolve()); + signal?.throwIfAborted(); + if ( + this.deps.selectedChat?.id !== chat.id || + this.deps.selectedChat.projectPath.trim() !== projectPath + ) { + throw new Error(m.workspace_project_changed()); + } + if (lease.snapshot.kind !== 'available') { + throw new Error( + lease.snapshot.kind === 'request-failed' + ? lease.snapshot.message + : m.workspace_project_unavailable(), + ); + } + } finally { + signal?.removeEventListener('abort', releaseOnAbort); + release(); + } + return { + context: + chat.status === 'draft' + ? { type: 'new-chat', chatId: chat.id, projectPath } + : { type: 'chat', chatId: chat.id }, + chatId: chat.id, + projectPath, + }; + } + + destroy(): void { + this.#destroyEffects(); + } +} diff --git a/web/src/lib/components/chat/prompt-composer-props.ts b/web/src/lib/components/chat/prompt-composer-props.ts new file mode 100644 index 000000000..88a99c366 --- /dev/null +++ b/web/src/lib/components/chat/prompt-composer-props.ts @@ -0,0 +1,22 @@ +import type { AgentSettingDescriptor } from '$shared/agent-integration'; +import type { ResendCandidate } from '$shared/chat-view'; +import type { JsonValue } from '$shared/json'; +import type { PermissionMode, ThinkingMode } from '$lib/types/chat'; +import type { ModelSelectorChange } from '$lib/components/model-selector/model-selector-types'; + +export interface PromptComposerProps { + onsubmit: () => void; + onSteerPreferredSubmit: () => void; + onModelChange?: (selection: ModelSelectorChange) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; + onThinkingModeChange?: (mode: ThinkingMode) => void; + onAgentSettingChange?: (descriptor: AgentSettingDescriptor, value: JsonValue) => void; + resendCandidates?: readonly ResendCandidate[]; + onExcludeResendCandidate?: (ordinal: number) => void; + directAdmissionPending?: boolean; + requiresQueuedSubmission?: boolean; + isVisible?: boolean; + isPresented?: boolean; + composerEditorOpenRequestId?: number; + onChooseProjectFolder?: (chatId: string) => void; +} diff --git a/web/src/lib/components/files/__tests__/FileTree.test.ts b/web/src/lib/components/files/__tests__/FileTree.test.ts index 07c61a499..a85308e33 100644 --- a/web/src/lib/components/files/__tests__/FileTree.test.ts +++ b/web/src/lib/components/files/__tests__/FileTree.test.ts @@ -84,6 +84,14 @@ function responseAt(directoryPath: string, entries: FileTreeEntry[]): FileTreeRe function renderReady(entries: FileTreeEntry[]) { const store = new FileTreeStore(); + store.setProjectState({ + kind: 'available', + project: { + chatId: 'chat-1', + projectPath: '/workspace/project', + effectiveProjectKey: '/workspace/project', + }, + }); store.navigation = { kind: 'ready', response: response(entries) }; const onFileSelect = vi.fn(); const result = render(FileTree, { diff --git a/web/src/lib/components/layout/AppShell.svelte b/web/src/lib/components/layout/AppShell.svelte index ee9a5991b..28deed937 100644 --- a/web/src/lib/components/layout/AppShell.svelte +++ b/web/src/lib/components/layout/AppShell.svelte @@ -104,6 +104,7 @@ get selectedChatId() { return sessions.selectedChatId; }, + projectPathRevision: (chatId) => sessions.projectPathRevision(chatId), onQuietRefresh: quietRefresh, isArchiveMutationPending: (chatId) => sessions.isArchiveMutationPending(chatId), startArchivingChats: (chatIds) => sessions.startArchivingChats(chatIds), @@ -488,11 +489,8 @@ closeMobileSidebar(); } - function handleChatProjectPathUpdated( - chatId: string, - patch: { projectPath: string; effectiveProjectKey: string }, - ): void { - sessions.patchChat(chatId, patch); + function handleChatProjectPathUpdated(chatId: string, patch: { projectPath: string }): void { + sessions.patchChat(chatId, { projectPath: patch.projectPath }); } function requestDeleteChat(chat: ChatSessionRecord): void { diff --git a/web/src/lib/components/layout/__tests__/AppShell.test.ts b/web/src/lib/components/layout/__tests__/AppShell.test.ts index 47be0ec71..bec899083 100644 --- a/web/src/lib/components/layout/__tests__/AppShell.test.ts +++ b/web/src/lib/components/layout/__tests__/AppShell.test.ts @@ -174,6 +174,7 @@ function installContext(): AppShellBreakpointWorkspace { rememberSelectedChat: vi.fn(), refreshChats: vi.fn(async () => undefined), quietRefreshChats: vi.fn(async () => undefined), + projectPathRevision: vi.fn(() => 0), upsertServerChat: vi.fn(), hasChat: vi.fn((chatId: string) => chatId === 'chat-test'), removeChat: vi.fn(), diff --git a/web/src/lib/components/layout/__tests__/CurrentChatMenu.test.ts b/web/src/lib/components/layout/__tests__/CurrentChatMenu.test.ts index 31df1fab6..00b7631f7 100644 --- a/web/src/lib/components/layout/__tests__/CurrentChatMenu.test.ts +++ b/web/src/lib/components/layout/__tests__/CurrentChatMenu.test.ts @@ -9,8 +9,6 @@ function chat(): ChatSessionRecord { id: 'chat-1', parentChat: null, projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat', agentId: 'claude', diff --git a/web/src/lib/components/layout/__tests__/app-shell-chat-navigation.logic.test.ts b/web/src/lib/components/layout/__tests__/app-shell-chat-navigation.logic.test.ts index b876acf81..4e2ade797 100644 --- a/web/src/lib/components/layout/__tests__/app-shell-chat-navigation.logic.test.ts +++ b/web/src/lib/components/layout/__tests__/app-shell-chat-navigation.logic.test.ts @@ -13,8 +13,6 @@ function chat( id, parentChat: null, projectPath: '/repo', - effectiveProjectKey: '/repo', - projectIdentityState: 'available', orderGroup: 'normal', title: id, agentId: 'claude', diff --git a/web/src/lib/components/onboarding/OnboardingChatLayoutPreview.svelte b/web/src/lib/components/onboarding/OnboardingChatLayoutPreview.svelte index 76e03f966..d1b730fcb 100644 --- a/web/src/lib/components/onboarding/OnboardingChatLayoutPreview.svelte +++ b/web/src/lib/components/onboarding/OnboardingChatLayoutPreview.svelte @@ -20,8 +20,6 @@ id: 'onboarding-layout-preview', parentChat: null, projectPath: '/workspace/aurora', - effectiveProjectKey: '/workspace/aurora', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Release checklist review', agentId: 'claude', diff --git a/web/src/lib/components/sidebar/SidebarProjectPathDialog.svelte b/web/src/lib/components/sidebar/SidebarProjectPathDialog.svelte index c4f0e90a2..d623373a1 100644 --- a/web/src/lib/components/sidebar/SidebarProjectPathDialog.svelte +++ b/web/src/lib/components/sidebar/SidebarProjectPathDialog.svelte @@ -174,6 +174,9 @@ {projectPathDialog?.currentProjectPath ?? ''}
+

+ {m.sidebar_project_path_queue_warning()} +

@@ -120,6 +123,7 @@ {visible} {onSendToChat} {onAppendToChatDraft} + {onChooseProjectFolder} {frameBridge} /> {/if} diff --git a/web/src/lib/components/workspace/ProjectAvailabilityNotice.svelte b/web/src/lib/components/workspace/ProjectAvailabilityNotice.svelte new file mode 100644 index 000000000..707b3bf67 --- /dev/null +++ b/web/src/lib/components/workspace/ProjectAvailabilityNotice.svelte @@ -0,0 +1,57 @@ + + +
+

{m.workspace_project_unavailable()}

+

{detail}

+

{projectPath}

+
+ + {#if onChooseFolder} + + {/if} +
+
diff --git a/web/src/lib/components/workspace/ProjectSurfaceGate.svelte b/web/src/lib/components/workspace/ProjectSurfaceGate.svelte index 5857c4774..1978608c6 100644 --- a/web/src/lib/components/workspace/ProjectSurfaceGate.svelte +++ b/web/src/lib/components/workspace/ProjectSurfaceGate.svelte @@ -2,31 +2,46 @@ import type { Snippet } from 'svelte'; import * as m from '$lib/paraglide/messages.js'; import type { WorkspaceProjectState } from '$lib/workspace/workspace-context.svelte.js'; + import { getProjectResolution } from '$lib/context'; + import type { ProjectTarget } from '$shared/project-resolution'; + import ProjectAvailabilityNotice from './ProjectAvailabilityNotice.svelte'; let { projectState, retainedProjectPath, retainedEffectiveProjectKey, + target, + onChooseFolder, children, }: { projectState: WorkspaceProjectState; retainedProjectPath: string | null; retainedEffectiveProjectKey: string | null; + target: ProjectTarget | null; + onChooseFolder?: () => void; children: Snippet; } = $props(); + const projectResolution = getProjectResolution(); const synchronized = $derived.by(() => { - if (projectState.kind === 'resolving') return false; if (projectState.kind === 'absent') return retainedEffectiveProjectKey === null; - return retainedEffectiveProjectKey === projectState.project.effectiveProjectKey; + return projectState.kind === 'available' + && retainedEffectiveProjectKey === projectState.project.effectiveProjectKey; }); const resolvingSamePath = $derived( - projectState.kind === 'resolving' && + (projectState.kind === 'unchecked' || projectState.kind === 'resolving') && retainedEffectiveProjectKey !== null && retainedProjectPath === projectState.context.projectPath, ); const blocked = $derived(projectState.kind === 'resolving' || !synchronized); const concealed = $derived(blocked && !resolvingSamePath); + + function retry(): void { + if (projectState.kind === 'absent' || projectState.kind === 'available') return; + if (!target) return; + const lease = projectResolution.retain(target); + void lease.retry().finally(() => lease.release()); + }
@@ -39,13 +54,31 @@ > {@render children()}
- {#if concealed && projectState.kind !== 'absent'} + {#if concealed && (projectState.kind === 'unchecked' || projectState.kind === 'resolving')}
{m.workspace_resolving_project()}
+ {:else if concealed && projectState.kind === 'unavailable'} +
+ +
+ {:else if concealed && projectState.kind === 'request-failed'} +
+ +
{:else if blocked} {m.workspace_resolving_project()} {/if} diff --git a/web/src/lib/components/workspace/WorkspaceRoot.svelte b/web/src/lib/components/workspace/WorkspaceRoot.svelte index e268540c1..1519e4eae 100644 --- a/web/src/lib/components/workspace/WorkspaceRoot.svelte +++ b/web/src/lib/components/workspace/WorkspaceRoot.svelte @@ -23,6 +23,8 @@ getGitQuickSummary, getChatProcessingReconciler, getModelCatalog, + getLocalSettings, + getProjectResolution, getSurfaceFrames, getTerminalRegistry, getWorkspaceCoordinator, @@ -74,6 +76,7 @@ import { cn } from '$lib/utils/cn'; import { terminalDisplayName } from '$lib/terminal/sessions/terminal-display-name.js'; import * as m from '$lib/paraglide/messages.js'; + import { projectTargetKey, type ProjectTarget } from '$shared/project-resolution'; let { isMobile, @@ -90,6 +93,8 @@ const terminals = getTerminalRegistry(); const sessions = getChatSessions(); const modelCatalog = getModelCatalog(); + const localSettings = getLocalSettings(); + const projectResolution = getProjectResolution(); const gitBranchActions = getGitBranchActions(); const gitQuickSummary = getGitQuickSummary(); const fileSessions = getFileSessions(); @@ -155,14 +160,68 @@ ), ), ); - const visibleGitProjects = $derived.by(() => - chatPresentations.flatMap(({ chatId }) => { + const visibleGitProjects = $derived.by(() => { + if (!localSettings.showQuickCommitTray) return []; + return chatPresentations.flatMap(({ chatId }) => { const chat = sessions.byId[chatId]; - return chat?.projectPath + if (!chat?.projectPath) return []; + const target = targetForChat(chat); + const resolution = projectResolution.snapshotFor(target); + return resolution.kind === 'available' ? [{ projectPath: chat.projectPath, isProcessing: chat.isProcessing }] : []; - }), + }); + }); + const visibleProjectTargetsKey = $derived.by(() => + visibleProjectTargets() + .map((target) => projectResolution.lifecycleKey(target)) + .sort() + .join('\u0000'), ); + + $effect(() => { + if (!visibleProjectTargetsKey) return; + const targets = untrack(visibleProjectTargets); + const leases = untrack(() => targets.map((target) => projectResolution.retain(target))); + return () => { + for (const lease of leases) lease.release(); + }; + }); + + $effect(() => { + const quickCommitVisible = localSettings.showQuickCommitTray; + if (!quickCommitVisible || !visibleProjectTargetsKey) return; + const targets = untrack(visibleProjectTargets); + const leases = untrack(() => + targets.map((target) => { + const lease = projectResolution.retain(target); + void lease.resolve(); + return lease; + }), + ); + return () => { + for (const lease of leases) lease.release(); + }; + }); + + function visibleProjectTargets(): ProjectTarget[] { + return [ + ...new Map( + chatPresentations.flatMap(({ chatId }) => { + const chat = sessions.byId[chatId]; + if (!chat?.projectPath) return []; + const target = targetForChat(chat); + return [[projectTargetKey(target), target] as const]; + }), + ).values(), + ]; + } + + function targetForChat(chat: { id: string; status: string; projectPath: string }): ProjectTarget { + return chat.status === 'draft' + ? { kind: 'path', projectPath: chat.projectPath } + : { kind: 'chat', chatId: chat.id, projectPath: chat.projectPath }; + } const rootState = new WorkspaceRootState({ get snapshot() { return snapshot; @@ -437,6 +496,11 @@ style={PORTABLE_SURFACE_STYLE} onSendToChat={sendToChat} onAppendToChatDraft={appendToChatDraft} + onChooseProjectFolder={modelCatalog.supportsUpdateProjectPath( + sessions.selectedChat?.agentId ?? '', + ) && sessions.selectedChat + ? () => chatActions.requestProjectPath(sessions.selectedChat!) + : undefined} frameBridge={rootState.frameBridge(surface.id)} /> {/key} diff --git a/web/src/lib/components/workspace/__tests__/ChatSurfaceTestStub.svelte b/web/src/lib/components/workspace/__tests__/ChatSurfaceTestStub.svelte index b1752e819..d0e829dbf 100644 --- a/web/src/lib/components/workspace/__tests__/ChatSurfaceTestStub.svelte +++ b/web/src/lib/components/workspace/__tests__/ChatSurfaceTestStub.svelte @@ -45,7 +45,8 @@ deleteQueue: async (surfaceId, chatId) => recordPanelAction(surfaceId, chatId, 'delete-queue'), stop: async (surfaceId, chatId) => recordPanelAction(surfaceId, chatId, 'stop'), openCommit: (surfaceId, chatId) => recordPanelAction(surfaceId, chatId, 'commit'), - toggleBranch: (surfaceId, chatId) => recordPanelAction(surfaceId, chatId, 'toggle-branch'), + toggleBranch: async (surfaceId, chatId) => + recordPanelAction(surfaceId, chatId, 'toggle-branch'), closeBranch: (surfaceId, chatId) => recordPanelAction(surfaceId, chatId, 'close-branch'), createBranch: (surfaceId, chatId) => recordPanelAction(surfaceId, chatId, 'create-branch'), switchBranch: async (surfaceId, chatId) => recordPanelAction(surfaceId, chatId, 'switch-branch'), diff --git a/web/src/lib/components/workspace/__tests__/ProjectSurfaceGate.test.ts b/web/src/lib/components/workspace/__tests__/ProjectSurfaceGate.test.ts index 21306184c..509a8dfe8 100644 --- a/web/src/lib/components/workspace/__tests__/ProjectSurfaceGate.test.ts +++ b/web/src/lib/components/workspace/__tests__/ProjectSurfaceGate.test.ts @@ -1,5 +1,6 @@ -import { render, screen } from '@testing-library/svelte'; -import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import type { ProjectTarget } from '$shared/project-resolution'; import ProjectSurfaceGateTestHost from './ProjectSurfaceGateTestHost.svelte'; const retained = { @@ -13,7 +14,7 @@ describe('ProjectSurfaceGate', () => { ...retained, projectState: { kind: 'resolving', - context: { chatId: 'draft', projectPath: '/project', effectiveProjectKey: null }, + context: { chatId: 'draft', projectPath: '/project' }, }, }); @@ -29,7 +30,7 @@ describe('ProjectSurfaceGate', () => { ...retained, projectState: { kind: 'resolving', - context: { chatId: 'draft', projectPath: '/other', effectiveProjectKey: null }, + context: { chatId: 'draft', projectPath: '/other' }, }, }); @@ -58,4 +59,35 @@ describe('ProjectSurfaceGate', () => { expect(action.parentElement?.hasAttribute('inert')).toBe(false); expect(container.firstElementChild?.getAttribute('aria-busy')).toBe('false'); }); + + it('shows actionable unavailable feedback and retries the explicit target', async () => { + const target = { kind: 'path' as const, projectPath: '/missing-project' }; + const fetchResolution = vi.fn(async (_target: ProjectTarget) => ({ + target, + resolution: { kind: 'unavailable' as const, reason: 'not-found' as const }, + })); + const onChooseFolder = vi.fn(); + render(ProjectSurfaceGateTestHost, { + props: { + ...retained, + target, + onChooseFolder, + fetchResolution, + projectState: { + kind: 'unavailable', + context: { chatId: 'draft', projectPath: target.projectPath }, + reason: 'not-found', + }, + }, + }); + + expect(screen.getByText('Project folder unavailable')).toBeTruthy(); + expect(screen.getByText('The folder could not be found.')).toBeTruthy(); + await fireEvent.click(screen.getByRole('button', { name: 'Choose folder' })); + await fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(onChooseFolder).toHaveBeenCalledOnce(); + await waitFor(() => expect(fetchResolution).toHaveBeenCalledOnce()); + expect(fetchResolution.mock.calls[0]?.[0]).toEqual(target); + }); }); diff --git a/web/src/lib/components/workspace/__tests__/ProjectSurfaceGateTestHost.svelte b/web/src/lib/components/workspace/__tests__/ProjectSurfaceGateTestHost.svelte index cf97f9571..ebde03cd8 100644 --- a/web/src/lib/components/workspace/__tests__/ProjectSurfaceGateTestHost.svelte +++ b/web/src/lib/components/workspace/__tests__/ProjectSurfaceGateTestHost.svelte @@ -1,18 +1,42 @@ - + diff --git a/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts b/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts index 6d7ea7c38..57e77fc27 100644 --- a/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts +++ b/web/src/lib/components/workspace/__tests__/WorkspaceRoot.test.ts @@ -8,6 +8,7 @@ import { } from '$lib/workspace/workspace-layout.svelte.js'; import { WorkspaceWindowDndController } from '$lib/workspace/window-dnd.svelte.js'; import { SurfaceFrameRegistry } from '$lib/workspace/surface-frame-registry.svelte.js'; +import { ProjectResolutionStore } from '$lib/workspace/project-resolution-store.svelte.js'; import { chatViewSurfaceId, portableSingletonDescriptor, @@ -27,6 +28,7 @@ import type { ConversationPanelRegistry, } from '$lib/chat/conversation/conversation-panel-registry.svelte.js'; import type { ChatMessagesRequest } from '$lib/api/chats.js'; +import type { ProjectTarget } from '$shared/project-resolution'; import * as m from '$lib/paraglide/messages.js'; import { resolveUnmeasuredWorkspaceSplit } from '$lib/workspace/__tests__/workspace-geometry-test-fixtures.js'; @@ -45,6 +47,7 @@ vi.mock('$lib/context', () => ({ getConversationPanels: () => testContext.current?.conversationPanels, getModelCatalog: () => testContext.current?.modelCatalog, getNotifications: () => testContext.current?.notifications, + getProjectResolution: () => testContext.current?.projectResolution, getSurfaceFrames: () => testContext.current?.surfaceFrames, getTerminalRegistry: () => testContext.current?.terminals, getWorkspaceCoordinator: () => testContext.current?.workspace, @@ -90,8 +93,6 @@ function chat( id, parentChat: null, projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title, agentId: 'codex', @@ -131,7 +132,7 @@ function emptyChatHistory(request: ChatMessagesRequest) { }; } -function installContext() { +function installContext({ showQuickCommitTray = false }: { showQuickCommitTray?: boolean } = {}) { const initial = reduceWorkspaceLayout(canonicalWorkspaceSnapshot(), [ { type: 'set-window-chat', windowId: 'window-main', chatId: 'chat-a' }, ]); @@ -375,8 +376,14 @@ function installContext() { }; const localSettings = { terminalFontSize: '13', + showQuickCommitTray, set: vi.fn(), }; + const fetchProjectResolution = vi.fn(async (target: ProjectTarget) => ({ + target, + resolution: { kind: 'available' as const, effectiveProjectKey: target.projectPath }, + })); + const projectResolution = new ProjectResolutionStore(fetchProjectResolution); const hostGeometry = { size: null, attach: () => undefined, @@ -413,8 +420,19 @@ function installContext() { }, ghCapability: { hasChecked: true, available: true }, notifications: { error: vi.fn() }, + projectResolution, + }; + return { + layout, + runtime, + workspace, + windowDnd, + terminals, + localSettings, + hostGeometry, + projectResolution, + fetchProjectResolution, }; - return { layout, runtime, workspace, windowDnd, terminals, localSettings, hostGeometry }; } const chatActions = { @@ -465,6 +483,7 @@ describe('WorkspaceRoot', () => { afterEach(() => { cleanup(); + (testContext.current?.projectResolution as ProjectResolutionStore | undefined)?.destroy(); testContext.current = null; vi.unstubAllGlobals(); }); @@ -531,8 +550,6 @@ describe('WorkspaceRoot', () => { const draft = chat('chat-a', 'Chat A', { status: 'draft', orderGroup: null, - effectiveProjectKey: null, - projectIdentityState: 'pending', }); sessions.selectedChat = draft; sessions.byId['chat-a'] = draft; @@ -545,6 +562,27 @@ describe('WorkspaceRoot', () => { expect(chatApiMocks.getChatMessages).not.toHaveBeenCalled(); }); + it('keeps visible project resolution stable across chat record updates', async () => { + const { layout, fetchProjectResolution } = installContext({ showQuickCommitTray: true }); + renderRoot(); + await waitFor(() => expect(fetchProjectResolution).toHaveBeenCalledOnce()); + const sessions = testContext.current?.sessions as { + byId: Record; + }; + + for (const patch of [ + { lastActivityAt: '2026-09-06T00:00:01.000Z' }, + { lastMessage: 'Updated preview' }, + { isProcessing: true, processingPhase: 'running' as const }, + ]) { + sessions.byId['chat-a'] = { ...sessions.byId['chat-a'], ...patch }; + layout.publish(layout.revision, { ...layout.snapshot }); + await tick(); + } + + expect(fetchProjectResolution).toHaveBeenCalledOnce(); + }); + it('adds Chat actions to the tab context menu', async () => { installContext(); renderRoot(); diff --git a/web/src/lib/context/index.ts b/web/src/lib/context/index.ts index bc95cafa4..b4fbca5aa 100644 --- a/web/src/lib/context/index.ts +++ b/web/src/lib/context/index.ts @@ -31,6 +31,7 @@ import type { ChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat-s import type { SnippetsStore } from '$lib/snippets/snippets-store.svelte'; import type { WorkspaceLayoutReader } from '$lib/workspace/surface-types'; import type { WorkspaceContextStore } from '$lib/workspace/workspace-context.svelte'; +import type { ProjectResolutionStore } from '$lib/workspace/project-resolution-store.svelte'; import type { TerminalRegistry } from '$lib/terminal/sessions/terminal-registry.svelte.js'; import type { WorkspaceCoordinator } from '$lib/workspace/workspace-coordinator.svelte'; import type { TransientLayerRegistry } from '$lib/workspace/transient-layers.svelte'; @@ -71,6 +72,7 @@ export const [getChatPreambleSelectionInvalidationHub, setChatPreambleSelectionI export const [getSnippets, setSnippets] = createContext(); export const [getWorkspaceLayout, setWorkspaceLayout] = createContext(); export const [getWorkspaceContext, setWorkspaceContext] = createContext(); +export const [getProjectResolution, setProjectResolution] = createContext(); export const [getTerminalRegistry, setTerminalRegistry] = createContext(); export const [getWorkspaceCoordinator, setWorkspaceCoordinator] = createContext(); diff --git a/web/src/lib/events/__tests__/router-integration.test.ts b/web/src/lib/events/__tests__/router-integration.test.ts index 8fc42374e..5e2200786 100644 --- a/web/src/lib/events/__tests__/router-integration.test.ts +++ b/web/src/lib/events/__tests__/router-integration.test.ts @@ -46,8 +46,6 @@ function chatRecord(overrides: Partial = {}): ChatSessionReco id: 'chat-a', parentChat: null, projectPath: '/repo', - effectiveProjectKey: '/repo', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Chat A', agentId: 'claude', @@ -295,7 +293,6 @@ describe('event router integration', () => { projectPath: '/workspace/worktree', effectiveProjectKey: '/workspace/worktree', previousProjectPath: '/workspace/repo', - previousEffectiveProjectKey: '/workspace/repo', }, ], stores, @@ -303,7 +300,6 @@ describe('event router integration', () => { expect(stores.sessions.patchChat).toHaveBeenCalledWith('chat-b', { projectPath: '/workspace/worktree', - effectiveProjectKey: '/workspace/worktree', }); }); diff --git a/web/src/lib/events/__tests__/sidebar-handler.logic.test.ts b/web/src/lib/events/__tests__/sidebar-handler.logic.test.ts index 5fee4fbfe..2c6361382 100644 --- a/web/src/lib/events/__tests__/sidebar-handler.logic.test.ts +++ b/web/src/lib/events/__tests__/sidebar-handler.logic.test.ts @@ -20,9 +20,7 @@ interface SidebarContextMocks extends SidebarContext { removeChat: Mock<(chatId: string) => void>; navigateAwayFromChat: Mock<(chatId: string) => void>; patchChatTitle: Mock<(chatId: string, title: string) => void>; - patchChatProjectPath: Mock< - (chatId: string, patch: { projectPath: string; effectiveProjectKey: string }) => void - >; + patchChatProjectPath: Mock<(chatId: string, patch: { projectPath: string }) => void>; patchLastReadAt: Mock<(chatId: string, lastReadAt: string) => void>; refreshChats: Mock<() => void>; removeChatTranscript: Mock<(chatId: string) => void>; @@ -34,10 +32,7 @@ function createSidebarContext(overrides: Partial = {}): Sid removeChat: vi.fn<(chatId: string) => void>(), navigateAwayFromChat: vi.fn<(chatId: string) => void>(), patchChatTitle: vi.fn<(chatId: string, title: string) => void>(), - patchChatProjectPath: - vi.fn< - (chatId: string, patch: { projectPath: string; effectiveProjectKey: string }) => void - >(), + patchChatProjectPath: vi.fn<(chatId: string, patch: { projectPath: string }) => void>(), patchLastReadAt: vi.fn<(chatId: string, lastReadAt: string) => void>(), refreshChats: vi.fn<() => void>(), removeChatTranscript: vi.fn<(chatId: string) => void>(), @@ -129,14 +124,12 @@ describe('handleChatProjectPathUpdated', () => { '/workspace/worktree', '/workspace/worktree', '/workspace/repo', - '/workspace/repo', ), ctx, ); expect(ctx.patchChatProjectPath).toHaveBeenCalledWith('chat-1', { projectPath: '/workspace/worktree', - effectiveProjectKey: '/workspace/worktree', }); }); @@ -149,7 +142,6 @@ describe('handleChatProjectPathUpdated', () => { '/workspace/worktree', '/workspace/worktree', '/workspace/repo', - '/workspace/repo', ), ctx, ); @@ -162,7 +154,10 @@ describe('handleChatListInvalidated', () => { it('calls refreshChats when chatId is present', () => { const ctx = createSidebarContext(); - handleChatListInvalidated(new ChatListRefreshRequestedMessage('chats-reordered', 'chat-1'), ctx); + handleChatListInvalidated( + new ChatListRefreshRequestedMessage('chats-reordered', 'chat-1'), + ctx, + ); expect(ctx.refreshChats).toHaveBeenCalledTimes(1); }); diff --git a/web/src/lib/events/handlers/sidebar.ts b/web/src/lib/events/handlers/sidebar.ts index 1251c6596..2f143d174 100644 --- a/web/src/lib/events/handlers/sidebar.ts +++ b/web/src/lib/events/handlers/sidebar.ts @@ -15,10 +15,7 @@ export interface SidebarContext { removeChat: (chatId: string) => void; navigateAwayFromChat: (chatId: string) => void; patchChatTitle: (chatId: string, title: string) => void; - patchChatProjectPath: ( - chatId: string, - patch: { projectPath: string; effectiveProjectKey: string }, - ) => void; + patchChatProjectPath: (chatId: string, patch: { projectPath: string }) => void; patchLastReadAt: (chatId: string, lastReadAt: string) => void; refreshChats: () => void; removeChatTranscript: (chatId: string) => void; @@ -47,10 +44,9 @@ export function handleChatProjectPathUpdated( msg: ChatProjectPathUpdatedMessage, ctx: SidebarContext, ) { - if (!msg.chatId || !msg.projectPath || !msg.effectiveProjectKey) return; + if (!msg.chatId || !msg.projectPath) return; ctx.patchChatProjectPath(msg.chatId, { projectPath: msg.projectPath, - effectiveProjectKey: msg.effectiveProjectKey, }); } diff --git a/web/src/lib/files/tree/__tests__/file-tree.test.ts b/web/src/lib/files/tree/__tests__/file-tree.test.ts index 9b77bfd4b..e67a0ae2c 100644 --- a/web/src/lib/files/tree/__tests__/file-tree.test.ts +++ b/web/src/lib/files/tree/__tests__/file-tree.test.ts @@ -301,7 +301,7 @@ describe('FileTreeStore', () => { await tick(); store.setProjectState({ kind: 'resolving', - context: { chatId: 'draft', projectPath: '/workspace/project', effectiveProjectKey: null }, + context: { chatId: 'draft', projectPath: '/workspace/project' }, }); expect(store.currentDirectoryPath).toBe('/workspace/project'); @@ -311,6 +311,47 @@ describe('FileTreeStore', () => { expect(filesApi.getTree).toHaveBeenCalledTimes(2); }); + it.each([ + { + label: 'unchecked', + projectState: { + kind: 'unchecked' as const, + context: { chatId: 'chat-1', projectPath: '/workspace/project' }, + }, + }, + { + label: 'unavailable', + projectState: { + kind: 'unavailable' as const, + context: { chatId: 'chat-1', projectPath: '/workspace/project' }, + reason: 'not-found' as const, + }, + }, + ])('blocks file requests while project identity is $label', async ({ projectState }) => { + vi.mocked(filesApi.getTree) + .mockImplementationOnce( + (_request, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ) + .mockResolvedValueOnce(response('/workspace/project')); + store.setProjectState(availableProject()); + store.activate(); + expect(filesApi.getTree).toHaveBeenCalledOnce(); + + store.setProjectState(projectState); + store.deactivate(); + store.activate(); + expect(filesApi.getTree).toHaveBeenCalledOnce(); + + store.setProjectState(availableProject()); + await tick(); + expect(filesApi.getTree).toHaveBeenCalledTimes(2); + }); + it('resets when the chat project path changes within the same effective project', async () => { vi.mocked(filesApi.getTree) .mockResolvedValueOnce(response('/workspace/project', [entry('old.ts', 'file')])) diff --git a/web/src/lib/files/tree/file-tree.svelte.ts b/web/src/lib/files/tree/file-tree.svelte.ts index 4344ead11..f3f76f54b 100644 --- a/web/src/lib/files/tree/file-tree.svelte.ts +++ b/web/src/lib/files/tree/file-tree.svelte.ts @@ -213,6 +213,7 @@ export class FileTreeStore { #chatProjectBreadcrumbs = $state.raw([]); #effectiveProjectKey = $state(''); #active = false; + #projectRequestsAllowed = false; #navigationController: AbortController | null = null; #refreshController: AbortController | null = null; #navigationToken = 0; @@ -322,8 +323,8 @@ export class FileTreeStore { } setProjectState(projectState: WorkspaceProjectState): void { - if (projectState.kind === 'resolving') return; if (projectState.kind === 'absent') { + this.#projectRequestsAllowed = false; this.#projectPath = null; this.#effectiveProjectKey = ''; this.#canonicalChatProjectPath = null; @@ -331,8 +332,14 @@ export class FileTreeStore { this.#resetBrowsingState(); return; } + if (projectState.kind !== 'available') { + this.#projectRequestsAllowed = false; + this.#abortRequests(); + return; + } const { project } = projectState; + this.#projectRequestsAllowed = true; const projectPathChanged = project.projectPath !== this.#projectPath; this.#projectPath = project.projectPath; if (!projectPathChanged && project.effectiveProjectKey === this.#effectiveProjectKey) { @@ -361,6 +368,7 @@ export class FileTreeStore { reset(): void { this.#active = false; + this.#projectRequestsAllowed = false; this.#projectPath = null; this.#effectiveProjectKey = ''; this.#canonicalChatProjectPath = null; @@ -373,7 +381,7 @@ export class FileTreeStore { this.#clearFilter(); this.#clearDirectoryCaches(); this.navigation = { kind: 'loading', target, previous }; - if (!this.#active) return; + if (!this.#active || !this.#projectRequestsAllowed) return; await this.#performNavigation(target, previous); } @@ -448,7 +456,7 @@ export class FileTreeStore { if (this.navigation.kind !== 'error') return; const { target, previous } = this.navigation; this.navigation = { kind: 'loading', target, previous }; - if (!this.#active) return; + if (!this.#active || !this.#projectRequestsAllowed) return; await this.#performNavigation(target, previous); } @@ -462,7 +470,7 @@ export class FileTreeStore { async refresh(): Promise { const response = this.readyResponse; - if (!response || this.isRefreshing || !this.#active) return; + if (!response || this.isRefreshing || !this.#active || !this.#projectRequestsAllowed) return; this.#refreshController?.abort(); const controller = new AbortController(); const token = ++this.#refreshToken; @@ -513,7 +521,12 @@ export class FileTreeStore { } async fetchChildren(path: string): Promise { - if (!this.#active || this.childrenCache.has(path) || this.loadingDirs.has(path)) { + if ( + !this.#active || + !this.#projectRequestsAllowed || + this.childrenCache.has(path) || + this.loadingDirs.has(path) + ) { return; } const controller = new AbortController(); @@ -674,7 +687,13 @@ export class FileTreeStore { } #resumePendingWork(): void { - if (!this.#active || !this.#effectiveProjectKey || !this.#projectPath) return; + if ( + !this.#active || + !this.#projectRequestsAllowed || + !this.#effectiveProjectKey || + !this.#projectPath + ) + return; if (this.navigation.kind === 'idle') { void this.navigateTo(this.#initialTarget()); return; @@ -696,6 +715,7 @@ export class FileTreeStore { target: FileTreeDirectoryTarget, previous: FileTreeResponse | null, ): Promise { + if (!this.#active || !this.#projectRequestsAllowed) return; this.#navigationController?.abort(); this.#abortRefresh(); this.#abortChildren(); diff --git a/web/src/lib/git/commit/__tests__/commit-controller.test.ts b/web/src/lib/git/commit/__tests__/commit-controller.test.ts index e38142463..af300274e 100644 --- a/web/src/lib/git/commit/__tests__/commit-controller.test.ts +++ b/web/src/lib/git/commit/__tests__/commit-controller.test.ts @@ -195,7 +195,7 @@ describe('CommitController', () => { await controller.setProjectState({ kind: 'resolving', - context: { chatId: 'draft', projectPath: '/project', effectiveProjectKey: null }, + context: { chatId: 'draft', projectPath: '/project' }, }); await controller.refreshTree(); controller.togglePath('unstaged.ts', true); diff --git a/web/src/lib/git/history/__tests__/git-history-surface-chat-switch.test.ts b/web/src/lib/git/history/__tests__/git-history-surface-chat-switch.test.ts index 44db5d017..26f00b892 100644 --- a/web/src/lib/git/history/__tests__/git-history-surface-chat-switch.test.ts +++ b/web/src/lib/git/history/__tests__/git-history-surface-chat-switch.test.ts @@ -44,7 +44,7 @@ function availableProject(chatId: string, projectPath: string) { function resolvingProject(chatId: string, projectPath: string) { return { kind: 'resolving' as const, - context: { chatId, projectPath, effectiveProjectKey: null }, + context: { chatId, projectPath }, }; } diff --git a/web/src/lib/git/pull-requests/__tests__/pull-requests-store.test.ts b/web/src/lib/git/pull-requests/__tests__/pull-requests-store.test.ts index 20abd097c..bd664f589 100644 --- a/web/src/lib/git/pull-requests/__tests__/pull-requests-store.test.ts +++ b/web/src/lib/git/pull-requests/__tests__/pull-requests-store.test.ts @@ -13,6 +13,14 @@ const getPullRequestMock = vi.mocked(prApi.getPullRequest); const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + function summary(number: number, over: Partial = {}): PullRequestSummary { return { number, @@ -185,7 +193,7 @@ describe('PullRequestsStore', () => { store.setProjectState({ kind: 'resolving', - context: { chatId: 'draft', projectPath: '/project', effectiveProjectKey: null }, + context: { chatId: 'draft', projectPath: '/project' }, }); await store.refresh(); await store.select(4); @@ -210,6 +218,77 @@ describe('PullRequestsStore', () => { expect(getPullRequestsMock).toHaveBeenCalledOnce(); }); + it('preserves an in-flight list across pending identity for the same project', async () => { + const list = deferred>>(); + let signal: AbortSignal | undefined; + getPullRequestsMock.mockImplementationOnce((_projectPath, options) => { + signal = options?.signal ?? undefined; + return list.promise; + }); + const store = createVisibleStore(); + store.setProject('/project', '/canonical/project'); + await vi.waitFor(() => expect(getPullRequestsMock).toHaveBeenCalledOnce()); + + store.setProjectState({ + kind: 'resolving', + context: { chatId: 'chat-2', projectPath: '/project' }, + }); + expect(signal?.aborted).toBe(false); + store.setProjectState({ + kind: 'available', + project: { + chatId: 'chat-2', + projectPath: '/project', + effectiveProjectKey: '/canonical/project', + }, + }); + list.resolve({ pulls: [summary(7)], repo: null }); + await tick(); + + expect(store.pulls.map((pull) => pull.number)).toEqual([7]); + expect(getPullRequestsMock).toHaveBeenCalledOnce(); + }); + + it('restarts an aborted refresh after definitive project recovery', async () => { + const refresh = deferred>>(); + let refreshSignal: AbortSignal | undefined; + getPullRequestsMock + .mockResolvedValueOnce({ pulls: [summary(1)], repo: null }) + .mockImplementationOnce((_projectPath, options) => { + refreshSignal = options?.signal ?? undefined; + return refresh.promise; + }) + .mockResolvedValueOnce({ pulls: [summary(2)], repo: null }); + const store = createVisibleStore(); + store.setProject('/project', '/canonical/project'); + await tick(); + const staleRefresh = store.refresh(); + await vi.waitFor(() => expect(getPullRequestsMock).toHaveBeenCalledTimes(2)); + + store.setProjectState({ + kind: 'unavailable', + context: { chatId: 'chat-1', projectPath: '/project' }, + reason: 'not-found', + }); + expect(refreshSignal?.aborted).toBe(true); + expect(store.isLoading).toBe(false); + store.setProjectState({ + kind: 'available', + project: { + chatId: 'chat-1', + projectPath: '/project', + effectiveProjectKey: '/canonical/project', + }, + }); + await tick(); + + expect(getPullRequestsMock).toHaveBeenCalledTimes(3); + expect(store.pulls.map((pull) => pull.number)).toEqual([2]); + refresh.resolve({ pulls: [summary(99)], repo: null }); + await staleRefresh; + expect(store.pulls.map((pull) => pull.number)).toEqual([2]); + }); + it('resumes an aborted selected detail when the surface becomes visible again', async () => { getPullRequestsMock.mockResolvedValue({ pulls: [summary(4)], repo: null }); getPullRequestMock @@ -232,6 +311,25 @@ describe('PullRequestsStore', () => { expect(store.detail?.number).toBe(4); }); + it('restarts a hidden list request when the surface reopens before abort settles', async () => { + let firstSignal: AbortSignal | undefined; + getPullRequestsMock + .mockImplementationOnce((_projectPath, options) => { + firstSignal = options?.signal ?? undefined; + return new Promise(() => undefined); + }) + .mockResolvedValueOnce({ pulls: [summary(6)], repo: null }); + const store = createVisibleStore(); + store.setProject('/proj'); + await vi.waitFor(() => expect(getPullRequestsMock).toHaveBeenCalledOnce()); + + store.setPresentationVisible(false); + expect(firstSignal?.aborted).toBe(true); + store.setPresentationVisible(true); + await vi.waitFor(() => expect(getPullRequestsMock).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(store.pulls.map((pull) => pull.number)).toEqual([6])); + }); + it('aborts reads when capability disappears and retries in place after recovery', async () => { let firstSignal: AbortSignal | undefined; getPullRequestsMock diff --git a/web/src/lib/git/pull-requests/pull-requests-store.svelte.ts b/web/src/lib/git/pull-requests/pull-requests-store.svelte.ts index 610909a7d..a3bac641f 100644 --- a/web/src/lib/git/pull-requests/pull-requests-store.svelte.ts +++ b/web/src/lib/git/pull-requests/pull-requests-store.svelte.ts @@ -83,15 +83,7 @@ export class PullRequestsStore implements PortableSingletonController { if (next === this.capabilityState) return; this.capabilityState = next; if (next !== 'available') { - this.#listController?.abort(); - this.#detailController?.abort(); - this.#listController = null; - this.#detailController = null; - this.#listGeneration += 1; - this.#detailGeneration += 1; - this.isLoading = false; - this.isDetailLoading = false; - this.#needsRefresh = Boolean(this.#projectPath); + this.#suspendRequests(); return; } if ( @@ -105,10 +97,15 @@ export class PullRequestsStore implements PortableSingletonController { } setProjectState(projectState: WorkspaceProjectState): void { - if (projectState.kind === 'resolving') { + if (projectState.kind === 'unchecked' || projectState.kind === 'resolving') { this.#projectIdentityPending = true; return; } + if (projectState.kind === 'unavailable' || projectState.kind === 'request-failed') { + this.#projectIdentityPending = true; + this.#suspendRequests(); + return; + } this.#projectIdentityPending = false; if (projectState.kind === 'absent') { this.setProject(null, null); @@ -145,11 +142,7 @@ export class PullRequestsStore implements PortableSingletonController { if (visible === this.#visible) return; this.#visible = visible; if (!visible) { - this.#listController?.abort(); - this.#detailController?.abort(); - this.isLoading = false; - this.isDetailLoading = false; - this.#needsRefresh = Boolean(this.#projectPath); + this.#suspendRequests(); return; } this.#activateIfNeeded(); @@ -278,7 +271,7 @@ export class PullRequestsStore implements PortableSingletonController { this.capabilityState !== 'available' ) return; - if (!this.hasLoaded || this.#needsRefresh) void this.refresh(); + if (!this.#listController && (!this.hasLoaded || this.#needsRefresh)) void this.refresh(); if ( this.selectedNumber !== null && this.detail?.number !== this.selectedNumber && @@ -288,6 +281,18 @@ export class PullRequestsStore implements PortableSingletonController { } } + #suspendRequests(): void { + this.#listController?.abort(); + this.#detailController?.abort(); + this.#listController = null; + this.#detailController = null; + this.#listGeneration += 1; + this.#detailGeneration += 1; + this.isLoading = false; + this.isDetailLoading = false; + this.#needsRefresh = Boolean(this.#projectPath); + } + #saveSnapshot(): void { const effectiveProjectKey = this.#effectiveProjectKey; const projectPath = this.#projectPath; diff --git a/web/src/lib/git/review/__tests__/git-compare-surface.test.ts b/web/src/lib/git/review/__tests__/git-compare-surface.test.ts index 02eaca2b1..8f5670e1d 100644 --- a/web/src/lib/git/review/__tests__/git-compare-surface.test.ts +++ b/web/src/lib/git/review/__tests__/git-compare-surface.test.ts @@ -404,7 +404,6 @@ describe('GitCompareSurfaceController', () => { context: { chatId: 'chat-a', projectPath: '/project', - effectiveProjectKey: null, }, }); diff --git a/web/src/lib/git/review/git-compare-surface.svelte.ts b/web/src/lib/git/review/git-compare-surface.svelte.ts index 6c6c5ac26..dbf138110 100644 --- a/web/src/lib/git/review/git-compare-surface.svelte.ts +++ b/web/src/lib/git/review/git-compare-surface.svelte.ts @@ -200,9 +200,9 @@ export class GitCompareSurfaceController implements PortableSingletonController function projectStateChatId(projectState: WorkspaceProjectState): string | null { if (projectState.kind === 'absent') return null; - return projectState.kind === 'resolving' - ? projectState.context.chatId - : projectState.project.chatId; + return projectState.kind === 'available' + ? projectState.project.chatId + : projectState.context.chatId; } function sameSession( diff --git a/web/src/lib/git/targets/__tests__/git-branch-selector-state.test.ts b/web/src/lib/git/targets/__tests__/git-branch-selector-state.test.ts index cbcb40760..ae7896124 100644 --- a/web/src/lib/git/targets/__tests__/git-branch-selector-state.test.ts +++ b/web/src/lib/git/targets/__tests__/git-branch-selector-state.test.ts @@ -166,6 +166,15 @@ describe('GitBranchSelectorState', () => { expectRefRequest('/project', '', UPDATED_DESC); }); + it('refuses branch results for a project the shared selector no longer owns', async () => { + branchSelector.setProject('/workspace/current', 'main', '/real/current'); + + await branchSelector.openBranchDropdown('/workspace/obsolete', '/real/obsolete'); + + expect(branchSelector.showBranchDropdown).toBe(false); + expect(getGitRefs).not.toHaveBeenCalled(); + }); + it('keeps generic ref loads on Name ascending without changing branch sort', async () => { branchSelector.branchSort = { ...UPDATED_DESC }; diff --git a/web/src/lib/git/targets/__tests__/git-target-session.test.ts b/web/src/lib/git/targets/__tests__/git-target-session.test.ts index fb2ddaaee..ddfd4672c 100644 --- a/web/src/lib/git/targets/__tests__/git-target-session.test.ts +++ b/web/src/lib/git/targets/__tests__/git-target-session.test.ts @@ -48,6 +48,7 @@ function createSession(options: { canChangeTarget?: () => boolean; invalidationVersion?: (effectiveProjectKey: string) => number; runMutation?: GitBranchSelectorStateOptions['runMutation']; + afterCheckout?: (projectPath: string) => void | Promise; }) { const changes: Array<{ path: string | null; @@ -67,6 +68,7 @@ function createSession(options: { }, invalidationVersion: options.invalidationVersion ?? (() => 0), canChangeTarget: options.canChangeTarget ?? (() => true), + afterCheckout: options.afterCheckout, onTargetChanged: (target, identity, reason, identityChanged) => { changes.push({ path: target?.projectPath ?? null, @@ -123,25 +125,179 @@ describe('GitTargetSessionController', () => { it('does not publish discovery that resolves while project identity is pending', async () => { const load = deferred<{ targets: GitTargetCandidate[] }>(); - api.getGitTargetCandidates.mockReturnValueOnce(load.promise); + let signal: AbortSignal | undefined; + api.getGitTargetCandidates + .mockImplementationOnce((_projectPath, options) => { + signal = options?.signal ?? undefined; + return load.promise; + }) + .mockResolvedValueOnce({ targets: [candidate('/old/worktree')] }); const { session, changes } = createSession({}); setProject(session, '/old', 'chat-old'); session.setPresentationVisible(true); const activation = session.activate(); + session.showTargetDialog = true; + session.branches.showBranchDropdown = true; session.setProjectState({ kind: 'resolving', context: { chatId: 'draft', projectPath: '/new', - effectiveProjectKey: null, }, }); + expect(signal?.aborted).toBe(false); + expect(session.showTargetDialog).toBe(true); + expect(session.branches.showBranchDropdown).toBe(true); load.resolve({ targets: [candidate('/old/worktree')] }); await activation; expect(session.activeProjectPath).toBe('/old'); expect(changes).toEqual([]); + + setProject(session, '/old', 'chat-old'); + await session.activate(); + + expect(api.getGitTargetCandidates).toHaveBeenCalledTimes(2); + expect(session.activeProjectPath).toBe('/old/worktree'); + expect(session.isLoadingTargets).toBe(false); + }); + + it('aborts discovery and closes dialogs for a definitive unavailable project', async () => { + const load = deferred<{ targets: GitTargetCandidate[] }>(); + let signal: AbortSignal | undefined; + api.getGitTargetCandidates.mockImplementationOnce((_projectPath, options) => { + signal = options?.signal ?? undefined; + return load.promise; + }); + const { session, changes } = createSession({}); + setProject(session, '/project', 'chat-project'); + session.setPresentationVisible(true); + const activation = session.activate(); + session.showTargetDialog = true; + session.branches.showBranchDropdown = true; + + session.setProjectState({ + kind: 'unavailable', + context: { chatId: 'chat-project', projectPath: '/project' }, + reason: 'not-found', + }); + + expect(signal?.aborted).toBe(true); + expect(session.showTargetDialog).toBe(false); + expect(session.branches.showBranchDropdown).toBe(false); + expect(session.isLoadingTargets).toBe(false); + load.resolve({ targets: [candidate('/stale')] }); + await activation; + expect(changes).toEqual([]); + expect(session.activeProjectPath).toBe('/project'); + expect(session.targets).toEqual([]); + }); + + it('starts a new activation when the same project recovers after a definitive failure', async () => { + api.getGitTargetCandidates + .mockImplementationOnce((_projectPath, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }) + .mockResolvedValueOnce({ targets: [candidate('/project')] }); + const { session, changes } = createSession({}); + setProject(session, '/project', 'chat-project'); + session.setPresentationVisible(true); + void session.activate(); + await vi.waitFor(() => expect(api.getGitTargetCandidates).toHaveBeenCalledTimes(1)); + + session.setProjectState({ + kind: 'request-failed', + context: { chatId: 'chat-project', projectPath: '/project' }, + message: 'Project check failed', + }); + setProject(session, '/project', 'chat-project'); + await session.activate(); + + expect(api.getGitTargetCandidates).toHaveBeenCalledTimes(2); + expect(changes.filter((change) => change.reason === 'project')).toHaveLength(1); + }); + + it.each(['unavailable', 'request-failed'] as const)( + 'does not apply an invalidation after project identity becomes %s', + async (kind) => { + api.getGitTargetCandidates.mockResolvedValueOnce({ targets: [candidate('/project')] }); + const { session, changes } = createSession({}); + setProject(session, '/project', 'chat-project'); + session.setPresentationVisible(true); + await session.activate(); + api.getGitTargetCandidates.mockImplementationOnce((_projectPath, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }); + + const refreshing = session.refreshForInvalidation('chat-project', 1); + await vi.waitFor(() => expect(api.getGitTargetCandidates).toHaveBeenCalledTimes(2)); + session.setProjectState( + kind === 'unavailable' + ? { + kind, + context: { chatId: 'chat-project', projectPath: '/project' }, + reason: 'not-found', + } + : { + kind, + context: { chatId: 'chat-project', projectPath: '/project' }, + message: 'Project check failed', + }, + ); + setProject(session, '/project', 'chat-project'); + + await expect(refreshing).resolves.toBe(false); + expect(changes.filter((change) => change.reason === 'invalidation')).toEqual([]); + }, + ); + + it('does not apply a manual target refresh after project identity fails', async () => { + const recovery = deferred<{ targets: GitTargetCandidate[] }>(); + api.getGitTargetCandidates.mockResolvedValueOnce({ targets: [candidate('/project')] }); + const { session, changes } = createSession({}); + setProject(session, '/project', 'chat-project'); + session.setPresentationVisible(true); + await session.activate(); + api.getGitTargetCandidates.mockImplementationOnce((_projectPath, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }); + api.getGitTargetCandidates.mockReturnValueOnce(recovery.promise); + + const refreshing = session.refreshTargets(); + await vi.waitFor(() => expect(api.getGitTargetCandidates).toHaveBeenCalledTimes(2)); + session.setProjectState({ + kind: 'unavailable', + context: { chatId: 'chat-project', projectPath: '/project' }, + reason: 'not-found', + }); + setProject(session, '/project', 'chat-project'); + const recoveryActivation = session.activate(); + + await refreshing; + expect(changes.map((change) => change.reason)).toEqual(['project']); + recovery.resolve({ targets: [candidate('/recovered')] }); + await recoveryActivation; + expect(changes.map((change) => change.reason)).toEqual(['project', 'project']); + expect(session.activeProjectPath).toBe('/recovered'); }); it('restores only its own cached target when switching chat projects', async () => { @@ -285,10 +441,7 @@ describe('GitTargetSessionController', () => { const result = await execute(); if (result.success) { invalidationVersion += 1; - await context.session?.refreshForInvalidation( - effectiveProjectKey, - invalidationVersion, - ); + await context.session?.refreshForInvalidation(effectiveProjectKey, invalidationVersion); } return result; }, @@ -305,18 +458,68 @@ describe('GitTargetSessionController', () => { created.session.setPresentationVisible(true); await created.session.activate(); - await expect( - created.session.switchBranch('feature', 'local-branch'), - ).resolves.toBe(true); - await expect( - created.session.refreshForInvalidation('chat', invalidationVersion), - ).resolves.toBe(false); + await expect(created.session.switchBranch('feature', 'local-branch')).resolves.toBe(true); + await expect(created.session.refreshForInvalidation('chat', invalidationVersion)).resolves.toBe( + false, + ); expect(api.getGitTargetCandidates).toHaveBeenCalledTimes(2); expect(created.changes.filter((change) => change.reason === 'checkout')).toHaveLength(1); - expect(created.changes.filter((change) => change.reason === 'invalidation')).toHaveLength( - 0, + expect(created.changes.filter((change) => change.reason === 'invalidation')).toHaveLength(0); + }); + + it('replays checkout invalidation after project availability interrupts reconciliation', async () => { + let invalidationVersion = 0; + const afterCheckout = vi.fn(); + api.getGitTargetCandidates + .mockResolvedValueOnce({ targets: [candidate('/chat')] }) + .mockImplementationOnce((_projectPath, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }) + .mockResolvedValue({ targets: [candidate('/chat', { branch: 'feature' })] }); + const runMutation = vi.fn( + async ( + _surfaceId: string, + _projectPath: string, + _effectiveProjectKey: string, + execute: () => Promise<{ success: boolean }>, + ) => { + const result = await execute(); + if (result.success) invalidationVersion += 1; + return result; + }, ); + const { session, changes } = createSession({ + runMutation, + invalidationVersion: () => invalidationVersion, + afterCheckout, + }); + setProject(session, '/chat', 'chat'); + session.setPresentationVisible(true); + await session.activate(); + + const switching = session.switchBranch('feature', 'local-branch'); + await vi.waitFor(() => expect(api.getGitTargetCandidates).toHaveBeenCalledTimes(2)); + session.setProjectState({ + kind: 'unavailable', + context: { chatId: 'chat', projectPath: '/chat' }, + reason: 'not-found', + }); + await expect(switching).resolves.toBe(true); + + expect(afterCheckout).not.toHaveBeenCalled(); + expect(changes.filter((change) => change.reason === 'checkout')).toEqual([]); + setProject(session, '/chat', 'chat'); + await session.activate(); + await expect(session.refreshForInvalidation('chat', invalidationVersion)).resolves.toBe(true); + + expect(changes.filter((change) => change.reason === 'invalidation')).toHaveLength(1); }); it('rejects target and branch changes while the owner is busy', async () => { @@ -346,12 +549,8 @@ describe('GitTargetSessionController', () => { second.setPresentationVisible(true); await Promise.all([first.activate(), second.activate()]); - await first.selectTarget( - candidate('/repo/a', { isCurrent: false, source: 'worktree' }), - ); - await second.selectTarget( - candidate('/repo/b', { isCurrent: false, source: 'worktree' }), - ); + await first.selectTarget(candidate('/repo/a', { isCurrent: false, source: 'worktree' })); + await second.selectTarget(candidate('/repo/b', { isCurrent: false, source: 'worktree' })); expect(first.activeProjectPath).toBe('/repo/a'); expect(second.activeProjectPath).toBe('/repo/b'); diff --git a/web/src/lib/git/targets/git-branch-selector-state.svelte.ts b/web/src/lib/git/targets/git-branch-selector-state.svelte.ts index 24ea5cf1b..71d54c7ba 100644 --- a/web/src/lib/git/targets/git-branch-selector-state.svelte.ts +++ b/web/src/lib/git/targets/git-branch-selector-state.svelte.ts @@ -144,7 +144,16 @@ export class GitBranchSelectorState { this.isCreatingBranch = false; } - async openBranchDropdown(projectPath: string): Promise { + async openBranchDropdown( + projectPath: string, + expectedEffectiveProjectKey?: string, + ): Promise { + if ( + expectedEffectiveProjectKey !== undefined && + (this.currentProjectPath !== projectPath || + this.currentEffectiveProjectKey !== expectedEffectiveProjectKey) + ) + return; this.showBranchDropdown = true; await this.searchBranchRefs(projectPath); } diff --git a/web/src/lib/git/targets/git-target-session.svelte.ts b/web/src/lib/git/targets/git-target-session.svelte.ts index d2180886f..35ebfa54c 100644 --- a/web/src/lib/git/targets/git-target-session.svelte.ts +++ b/web/src/lib/git/targets/git-target-session.svelte.ts @@ -63,9 +63,11 @@ export class GitTargetSessionController implements PortableSingletonController { #requestAbort: AbortController | null = null; #requestGeneration = 0; #contextGeneration = 0; + #targetApplicationGeneration = 0; #activation: | { contextGeneration: number; + applicationGeneration: number; promise: Promise; } | null = null; @@ -117,8 +119,13 @@ export class GitTargetSessionController implements PortableSingletonController { } setProjectState(projectState: WorkspaceProjectState): void { - if (projectState.kind === 'resolving') { + if (projectState.kind === 'unchecked' || projectState.kind === 'resolving') { this.projectIdentityPending = true; + return; + } + if (projectState.kind === 'unavailable' || projectState.kind === 'request-failed') { + this.projectIdentityPending = true; + this.#targetApplicationGeneration += 1; this.closeDialogs(); this.#cancelTargetRequest(); return; @@ -148,7 +155,11 @@ export class GitTargetSessionController implements PortableSingletonController { async activate(): Promise { if (!this.presentationVisible || this.projectIdentityPending) return; const contextGeneration = this.#contextGeneration; - if (this.#activation?.contextGeneration === contextGeneration) { + const applicationGeneration = this.#targetApplicationGeneration; + if ( + this.#activation?.contextGeneration === contextGeneration && + this.#activation.applicationGeneration === applicationGeneration + ) { return this.#activation.promise; } const activation = (async () => { @@ -156,7 +167,8 @@ export class GitTargetSessionController implements PortableSingletonController { if ( !this.presentationVisible || this.projectIdentityPending || - contextGeneration !== this.#contextGeneration + contextGeneration !== this.#contextGeneration || + applicationGeneration !== this.#targetApplicationGeneration ) { return; } @@ -165,7 +177,7 @@ export class GitTargetSessionController implements PortableSingletonController { const tracked = activation.finally(() => { if (this.#activation?.promise === tracked) this.#activation = null; }); - this.#activation = { contextGeneration, promise: tracked }; + this.#activation = { contextGeneration, applicationGeneration, promise: tracked }; return tracked; } @@ -291,16 +303,10 @@ export class GitTargetSessionController implements PortableSingletonController { this.activeTarget = fallback; return previousIdentity !== this.identity; } finally { - if ( - this.#isCurrentTargetRequest( - generation, - contextGeneration, - projectKey, - controller.signal, - ) - ) { + if (this.#ownsTargetRequest(generation, contextGeneration, projectKey, controller.signal)) { this.isLoadingTargets = false; this.#requestAbort = null; + if (this.projectIdentityPending) this.#lastTargetFetchKey = null; } } } @@ -330,11 +336,16 @@ export class GitTargetSessionController implements PortableSingletonController { return false; } storeMostRecent(this.#pendingInvalidationVersions, effectiveProjectKey, version); + const applicationGeneration = this.#targetApplicationGeneration; + const contextGeneration = this.#contextGeneration; try { await this.ensureTargets(true); if ( !this.presentationVisible || + this.projectIdentityPending || effectiveProjectKey !== this.effectiveProjectKey || + contextGeneration !== this.#contextGeneration || + applicationGeneration !== this.#targetApplicationGeneration || this.#pendingInvalidationVersions.get(effectiveProjectKey) !== version ) { return false; @@ -350,8 +361,17 @@ export class GitTargetSessionController implements PortableSingletonController { } async refreshTargets(): Promise { + const applicationGeneration = this.#targetApplicationGeneration; + const contextGeneration = this.#contextGeneration; await this.ensureTargets(true); - if (this.presentationVisible) await this.#applyTarget('project', true); + if ( + this.presentationVisible && + !this.projectIdentityPending && + contextGeneration === this.#contextGeneration && + applicationGeneration === this.#targetApplicationGeneration + ) { + await this.#applyTarget('project', true); + } } closeDialogs(): void { @@ -420,9 +440,8 @@ export class GitTargetSessionController implements PortableSingletonController { this.#updateActiveBranch(nextBranch); await this.ensureTargets(true); if (identity !== this.identity) return true; - await this.#applyTarget('checkout', true, nextBranch); - await this.deps.afterCheckout?.(projectPath); - reconciled = true; + reconciled = await this.#applyTarget('checkout', true, nextBranch); + if (reconciled) await this.deps.afterCheckout?.(projectPath); return true; }; try { @@ -507,8 +526,16 @@ export class GitTargetSessionController implements PortableSingletonController { async #reconcileSelectedTarget(): Promise { const previousIdentity = this.identity; + const applicationGeneration = this.#targetApplicationGeneration; + const contextGeneration = this.#contextGeneration; const identityChanged = await this.ensureTargets(true); - if (identityChanged && previousIdentity !== this.identity) { + if ( + identityChanged && + previousIdentity !== this.identity && + !this.projectIdentityPending && + applicationGeneration === this.#targetApplicationGeneration && + contextGeneration === this.#contextGeneration + ) { await this.#applyTarget('selection'); } } @@ -517,7 +544,8 @@ export class GitTargetSessionController implements PortableSingletonController { reason: GitTargetChangeReason, force = false, branchOverride?: string, - ): Promise { + ): Promise { + if (this.projectIdentityPending) return false; const target = this.activeTarget ?? this.fallbackTarget; const projectPath = target?.projectPath ?? null; const effectiveProjectKey = this.effectiveProjectKey; @@ -525,7 +553,7 @@ export class GitTargetSessionController implements PortableSingletonController { target && effectiveProjectKey ? gitTargetIdentity(effectiveProjectKey, target) : null; - if (!force && identity === this.#appliedIdentity) return; + if (!force && identity === this.#appliedIdentity) return false; const identityChanged = identity !== this.#appliedIdentity; this.#appliedIdentity = identity; if (identityChanged) { @@ -542,6 +570,7 @@ export class GitTargetSessionController implements PortableSingletonController { ); } await this.deps.onTargetChanged(target, identity, reason, identityChanged); + return true; } #rememberTarget(): void { @@ -570,14 +599,25 @@ export class GitTargetSessionController implements PortableSingletonController { contextGeneration: number, projectKey: string, signal: AbortSignal, + ): boolean { + return ( + this.#ownsTargetRequest(generation, contextGeneration, projectKey, signal) && + !this.projectIdentityPending && + this.presentationVisible + ); + } + + #ownsTargetRequest( + generation: number, + contextGeneration: number, + projectKey: string, + signal: AbortSignal, ): boolean { return ( !signal.aborted && generation === this.#requestGeneration && contextGeneration === this.#contextGeneration && - projectKey === this.effectiveProjectKey && - !this.projectIdentityPending && - this.presentationVisible + projectKey === this.effectiveProjectKey ); } } diff --git a/web/src/lib/git/workbench/__tests__/git-workbench-surface-chat-switch.test.ts b/web/src/lib/git/workbench/__tests__/git-workbench-surface-chat-switch.test.ts index 64a1a5159..6452407d0 100644 --- a/web/src/lib/git/workbench/__tests__/git-workbench-surface-chat-switch.test.ts +++ b/web/src/lib/git/workbench/__tests__/git-workbench-surface-chat-switch.test.ts @@ -142,7 +142,7 @@ function availableProject(chatId: string, projectPath: string) { function resolvingProject(chatId: string, projectPath: string) { return { kind: 'resolving' as const, - context: { chatId, projectPath, effectiveProjectKey: null }, + context: { chatId, projectPath }, }; } diff --git a/web/src/lib/sidebar/search/__tests__/search-result-order.logic.test.ts b/web/src/lib/sidebar/search/__tests__/search-result-order.logic.test.ts index 5fc0e2a69..6587eb3ae 100644 --- a/web/src/lib/sidebar/search/__tests__/search-result-order.logic.test.ts +++ b/web/src/lib/sidebar/search/__tests__/search-result-order.logic.test.ts @@ -17,8 +17,6 @@ function chat( id, parentChat: null, projectPath: '/workspace', - effectiveProjectKey: '/workspace', - projectIdentityState: 'available', orderGroup: 'normal', title: id, agentId: 'claude', diff --git a/web/src/lib/sidebar/search/__tests__/sidebar-search-store.test.ts b/web/src/lib/sidebar/search/__tests__/sidebar-search-store.test.ts index 56ebac196..759088399 100644 --- a/web/src/lib/sidebar/search/__tests__/sidebar-search-store.test.ts +++ b/web/src/lib/sidebar/search/__tests__/sidebar-search-store.test.ts @@ -19,8 +19,6 @@ function makeChat(overrides: Partial): ChatSessionRecord { return { id: 'chat-1', projectPath: '/workspace/project', - effectiveProjectKey: '/workspace/project', - projectIdentityState: 'available', orderGroup: 'normal', title: 'Test chat', agentId: 'claude', diff --git a/web/src/lib/snippets/__tests__/snippet-expansion-controller.test.ts b/web/src/lib/snippets/__tests__/snippet-expansion-controller.test.ts index 7761c6846..b8338c1f1 100644 --- a/web/src/lib/snippets/__tests__/snippet-expansion-controller.test.ts +++ b/web/src/lib/snippets/__tests__/snippet-expansion-controller.test.ts @@ -66,6 +66,31 @@ describe('SnippetExpansionController', () => { expect(await running).toEqual({ kind: 'cancelled' }); }); + it('owns and cancels preparation before expansion starts', async () => { + let resolvePreparation!: (value: { + request: ExpandSnippetRequest; + prepared: string; + }) => void; + let preparationSignal!: AbortSignal; + const expand = vi.fn(); + const controller = new SnippetExpansionController({ expand }); + const running = controller.runPrepared('review', (signal) => { + preparationSignal = signal; + return new Promise<{ request: ExpandSnippetRequest; prepared: string }>((resolve) => { + resolvePreparation = resolve; + }); + }); + + expect(controller.pending).toBe(true); + expect(controller.pendingShortName).toBe('review'); + controller.cancel(); + expect(preparationSignal.aborted).toBe(true); + resolvePreparation({ request, prepared: 'context' }); + + expect(await running).toEqual({ kind: 'cancelled' }); + expect(expand).not.toHaveBeenCalled(); + }); + it('clears pending state and propagates expansion errors', async () => { const controller = new SnippetExpansionController({ expand: vi.fn().mockRejectedValue(new Error('unavailable')), diff --git a/web/src/lib/snippets/snippet-expansion-controller.svelte.ts b/web/src/lib/snippets/snippet-expansion-controller.svelte.ts index 18b493f3c..fd30f69b8 100644 --- a/web/src/lib/snippets/snippet-expansion-controller.svelte.ts +++ b/web/src/lib/snippets/snippet-expansion-controller.svelte.ts @@ -5,10 +5,28 @@ export type SnippetExpansionResult = | { kind: 'expanded'; response: ExpandSnippetResponse; generation: number } | { kind: 'cancelled' }; +export type PreparedSnippetExpansionResult = + | { + kind: 'expanded'; + response: ExpandSnippetResponse; + generation: number; + prepared: T; + } + | { kind: 'cancelled' }; + export interface SnippetExpansionControllerDeps { expand?: typeof expandSnippet; } +type PreparedSnippetExpansion = { + request: ExpandSnippetRequest; + prepared: T; +}; + +type PrepareSnippetExpansion = ( + signal: AbortSignal, +) => PreparedSnippetExpansion | Promise>; + export class SnippetExpansionController { pending = $state(false); pendingShortName = $state(null); @@ -18,19 +36,48 @@ export class SnippetExpansionController { constructor(private readonly deps: SnippetExpansionControllerDeps = {}) {} async run(request: ExpandSnippetRequest): Promise { + const result = await this.#run(request.shortName, () => ({ + request, + prepared: undefined, + })); + return result.kind === 'cancelled' + ? result + : { + kind: 'expanded', + response: result.response, + generation: result.generation, + }; + } + + runPrepared( + shortName: string, + prepare: PrepareSnippetExpansion, + ): Promise> { + return this.#run(shortName, prepare); + } + + async #run( + shortName: string, + prepare: PrepareSnippetExpansion, + ): Promise> { if (this.pending) return { kind: 'cancelled' }; const generation = ++this.#generation; const controller = new AbortController(); this.#abortController = controller; this.pending = true; - this.pendingShortName = request.shortName; + this.pendingShortName = shortName; try { + const prepared = prepare(controller.signal); + const preparation = prepared instanceof Promise ? await prepared : prepared; + if (controller.signal.aborted || generation !== this.#generation) { + return { kind: 'cancelled' }; + } const expand = this.deps.expand ?? expandSnippet; - const response = await expand(request, { signal: controller.signal }); + const response = await expand(preparation.request, { signal: controller.signal }); if (controller.signal.aborted || generation !== this.#generation) { return { kind: 'cancelled' }; } - return { kind: 'expanded', response, generation }; + return { kind: 'expanded', response, generation, prepared: preparation.prepared }; } catch (error) { if (controller.signal.aborted || generation !== this.#generation) { return { kind: 'cancelled' }; diff --git a/web/src/lib/types/chat-session.ts b/web/src/lib/types/chat-session.ts index 596141284..7840bcedc 100644 --- a/web/src/lib/types/chat-session.ts +++ b/web/src/lib/types/chat-session.ts @@ -29,8 +29,6 @@ export interface ChatSessionRecord { id: string; parentChat: ParentChatRef | null; projectPath: string; - effectiveProjectKey: string | null; - projectIdentityState: 'pending' | 'available'; orderGroup: ChatOrderGroup | null; title: string; agentId: SessionAgentId; diff --git a/web/src/lib/workspace/__tests__/mobile-presentation-planner.test.ts b/web/src/lib/workspace/__tests__/mobile-presentation-planner.test.ts index a91af4645..a4fb32dd0 100644 --- a/web/src/lib/workspace/__tests__/mobile-presentation-planner.test.ts +++ b/web/src/lib/workspace/__tests__/mobile-presentation-planner.test.ts @@ -9,7 +9,7 @@ import { MobilePresentationPlanner } from '../mobile-presentation-planner.js'; describe('MobilePresentationPlanner', () => { it('records and restores a route- and project-valid transient invoker', () => { - const context = { chatId: 'chat-a', effectiveProjectKey: 'project-a' }; + const context = { chatId: 'chat-a', projectPath: '/project-a' }; const planner = new MobilePresentationPlanner({ getContext: () => context, getRouteIdentity: () => '/chat/chat-a', @@ -45,7 +45,7 @@ describe('MobilePresentationPlanner', () => { invokerSurfaceId: 'singleton:git', invokerHost: 'mobile', chatId: 'chat-a', - effectiveProjectKey: 'project-a', + projectPath: '/project-a', routeIdentity: '/chat/chat-a', }, ]); @@ -57,7 +57,7 @@ describe('MobilePresentationPlanner', () => { it('ignores stale return entries and falls back to non-excluded mobile recency', () => { let routeIdentity = '/chat/chat-a'; - const context = { chatId: 'chat-a', effectiveProjectKey: 'project-a' }; + const context = { chatId: 'chat-a', projectPath: '/project-a' }; const planner = new MobilePresentationPlanner({ getContext: () => context, getRouteIdentity: () => routeIdentity, diff --git a/web/src/lib/workspace/__tests__/project-resolution-store.test.ts b/web/src/lib/workspace/__tests__/project-resolution-store.test.ts new file mode 100644 index 000000000..dbfad3ee7 --- /dev/null +++ b/web/src/lib/workspace/__tests__/project-resolution-store.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ApiError } from '$lib/api/client'; +import type { ProjectTarget } from '$shared/project-resolution'; +import { ProjectResolutionStore } from '../project-resolution-store.svelte'; + +const CHAT_ID = '1783725900000800'; +const target = { + kind: 'chat', + chatId: CHAT_ID, + projectPath: '/workspace/project', +} as const; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('ProjectResolutionStore', () => { + it('retains observations without fetching until an owner requests resolution', () => { + const fetchResolution = vi.fn(); + const store = new ProjectResolutionStore(fetchResolution); + const lease = store.retain(target); + + expect(fetchResolution).not.toHaveBeenCalled(); + expect(lease.snapshot).toEqual({ kind: 'unchecked' }); + + lease.release(); + }); + + it('coalesces retained demand and prunes after the final release', async () => { + const result = deferred<{ + target: typeof target; + resolution: { kind: 'available'; effectiveProjectKey: string }; + }>(); + const fetchResolution = vi.fn(() => result.promise); + const store = new ProjectResolutionStore(fetchResolution); + const first = store.retain(target); + const second = store.retain(target); + + const firstResolve = first.resolve(); + const secondResolve = second.resolve(); + expect(fetchResolution).toHaveBeenCalledTimes(1); + expect(first.snapshot).toEqual({ kind: 'resolving' }); + result.resolve({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/real/project' }, + }); + await Promise.all([firstResolve, secondResolve]); + expect(second.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/project', + }); + + first.release(); + expect(store.snapshotFor(target).kind).toBe('available'); + second.release(); + expect(store.snapshotFor(target)).toEqual({ kind: 'unchecked' }); + }); + + it('fences a released record from a later lease for the same target', async () => { + const stale = deferred<{ + target: typeof target; + resolution: { kind: 'available'; effectiveProjectKey: string }; + }>(); + const fetchResolution = vi + .fn() + .mockReturnValueOnce(stale.promise) + .mockResolvedValueOnce({ + target, + resolution: { kind: 'available' as const, effectiveProjectKey: '/real/current' }, + }); + const store = new ProjectResolutionStore(fetchResolution); + const first = store.retain(target); + const firstResolution = first.resolve(); + first.release(); + const replacement = store.retain(target); + + stale.resolve({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/real/stale' }, + }); + await firstResolution; + + expect(first.snapshot).toEqual({ kind: 'unchecked' }); + expect(replacement.snapshot).toEqual({ kind: 'unchecked' }); + await replacement.resolve(); + expect(fetchResolution).toHaveBeenCalledTimes(2); + expect(replacement.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/current', + }); + replacement.release(); + }); + + it('preserves an available observation while a fresh resolution is pending', async () => { + const refresh = deferred<{ + target: typeof target; + resolution: { kind: 'available'; effectiveProjectKey: string }; + }>(); + const fetchResolution = vi + .fn() + .mockResolvedValueOnce({ + target, + resolution: { kind: 'available' as const, effectiveProjectKey: '/real/project' }, + }) + .mockReturnValueOnce(refresh.promise); + const store = new ProjectResolutionStore(fetchResolution); + const lease = store.retain(target); + await lease.resolve(); + + const pending = lease.resolve(); + + expect(lease.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/project', + }); + refresh.resolve({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/real/project-refreshed' }, + }); + await pending; + expect(lease.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/project-refreshed', + }); + lease.release(); + }); + + it('aborts and ignores a late result after the target becomes obsolete', async () => { + const result = deferred<{ + target: typeof target; + resolution: { kind: 'available'; effectiveProjectKey: string }; + }>(); + let capturedSignal: AbortSignal | undefined; + const store = new ProjectResolutionStore((_target, signal) => { + capturedSignal = signal; + return result.promise; + }); + const lease = store.retain(target); + const pending = lease.resolve(); + store.markObsoleteChatTargets(CHAT_ID, '/workspace/replacement'); + + expect(capturedSignal?.aborted).toBe(true); + result.resolve({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/stale/project' }, + }); + await pending; + expect(lease.snapshot).toEqual({ kind: 'unchecked' }); + lease.release(); + }); + + it('preserves a matching destination while a relocation echo is pending', async () => { + const destination = { ...target, projectPath: '/workspace/replacement' } as const; + const result = deferred<{ + target: typeof destination; + resolution: { kind: 'available'; effectiveProjectKey: string }; + }>(); + let capturedSignal: AbortSignal | undefined; + const store = new ProjectResolutionStore((_target, signal) => { + capturedSignal = signal; + return result.promise; + }); + const lease = store.retain(destination); + const pending = lease.resolve(); + + store.markObsoleteChatTargets(CHAT_ID, destination.projectPath); + + expect(capturedSignal?.aborted).toBe(false); + expect(lease.snapshot).toEqual({ kind: 'resolving' }); + result.resolve({ + target: destination, + resolution: { kind: 'available', effectiveProjectKey: '/real/replacement' }, + }); + await pending; + expect(lease.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/replacement', + }); + lease.release(); + }); + + it('preserves a resolved destination across duplicate relocation notifications', async () => { + const destination = { ...target, projectPath: '/workspace/replacement' } as const; + const fetchResolution = vi.fn(async (requested: ProjectTarget) => ({ + target: requested, + resolution: { + kind: 'available' as const, + effectiveProjectKey: `/real${requested.projectPath}`, + }, + })); + const store = new ProjectResolutionStore(fetchResolution); + const oldLease = store.retain(target); + const lease = store.retain(destination); + await Promise.all([oldLease.resolve(), lease.resolve()]); + + store.markObsoleteChatTargets(CHAT_ID, destination.projectPath); + store.markObsoleteChatTargets(CHAT_ID, destination.projectPath); + + expect(fetchResolution).toHaveBeenCalledTimes(2); + expect(oldLease.snapshot).toEqual({ kind: 'unchecked' }); + expect(lease.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/workspace/replacement', + }); + oldLease.release(); + lease.release(); + }); + + it('keeps the current destination resolved when an old binding reports a change', async () => { + const destination = { ...target, projectPath: '/workspace/replacement' } as const; + const oldResult = deferred(); + const onBindingChanged = vi.fn(); + const store = new ProjectResolutionStore(async (requested) => { + if (requested.projectPath === target.projectPath) return oldResult.promise; + return { + target: requested, + resolution: { + kind: 'available', + effectiveProjectKey: '/real/replacement', + } as const, + }; + }, onBindingChanged); + const oldLease = store.retain(target); + const destinationLease = store.retain(destination); + const oldPending = oldLease.resolve(); + await destinationLease.resolve(); + + oldResult.reject(new ApiError(409, 'changed', 'PROJECT_PATH_CHANGED')); + await oldPending; + + expect(oldLease.snapshot).toEqual({ kind: 'request-failed', message: 'changed' }); + expect(destinationLease.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/replacement', + }); + expect(onBindingChanged).toHaveBeenCalledWith(target); + oldLease.release(); + destinationLease.release(); + }); + + it('supersedes a pending request when Retry starts a fresh inspection', async () => { + const first = deferred<{ + target: typeof target; + resolution: { kind: 'available'; effectiveProjectKey: string }; + }>(); + const second = deferred<{ + target: typeof target; + resolution: { kind: 'unavailable'; reason: 'not-found' }; + }>(); + const signals: AbortSignal[] = []; + const fetchResolution = vi.fn((_target, signal: AbortSignal) => { + signals.push(signal); + return signals.length === 1 ? first.promise : second.promise; + }); + const store = new ProjectResolutionStore(fetchResolution); + const lease = store.retain(target); + const original = lease.resolve(); + const retry = lease.retry(); + + expect(signals[0]?.aborted).toBe(true); + expect(signals[1]?.aborted).toBe(false); + first.resolve({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/stale/project' }, + }); + await Promise.resolve(); + expect(lease.snapshot).toEqual({ kind: 'resolving' }); + second.resolve({ target, resolution: { kind: 'unavailable', reason: 'not-found' } }); + await Promise.all([original, retry]); + expect(lease.snapshot).toEqual({ kind: 'unavailable', reason: 'not-found' }); + lease.release(); + }); + + it('ignores an obsolete binding rejection after same-target retry', async () => { + const first = deferred(); + const second = deferred<{ + target: typeof target; + resolution: { kind: 'available'; effectiveProjectKey: string }; + }>(); + const onBindingChanged = vi.fn(); + const fetchResolution = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const store = new ProjectResolutionStore(fetchResolution, onBindingChanged); + const lease = store.retain(target); + const original = lease.resolve(); + const retry = lease.retry(); + + first.reject(new ApiError(409, 'obsolete', 'PROJECT_PATH_CHANGED')); + second.resolve({ + target, + resolution: { kind: 'available', effectiveProjectKey: '/real/current' }, + }); + await Promise.all([original, retry]); + + expect(lease.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/current', + }); + expect(onBindingChanged).not.toHaveBeenCalled(); + lease.release(); + }); + + it('retires the prior incarnation across repeated A/B/A binding changes', async () => { + const targetB = { ...target, projectPath: '/workspace/project-b' } as const; + const fetchResolution = vi.fn(async (requested: ProjectTarget) => ({ + target: requested, + resolution: { + kind: 'available' as const, + effectiveProjectKey: `/real${requested.projectPath}`, + }, + })); + const store = new ProjectResolutionStore(fetchResolution); + + const firstLifecycleKey = store.lifecycleKey(target); + const firstA = store.retain(target); + await firstA.resolve(); + store.markObsoleteChatTargets(CHAT_ID, targetB.projectPath); + store.markObsoleteChatTargets(CHAT_ID, target.projectPath); + const secondLifecycleKey = store.lifecycleKey(target); + const secondA = store.retain(target); + + expect(secondA.snapshot).toEqual({ kind: 'unchecked' }); + expect(secondLifecycleKey).not.toBe(firstLifecycleKey); + expect(firstA.snapshot).toEqual({ kind: 'unchecked' }); + await secondA.resolve(); + expect(fetchResolution).toHaveBeenCalledTimes(2); + firstA.release(); + secondA.release(); + }); + + it('removes retained targets and resets the chat lifecycle revision', async () => { + const fetchResolution = vi.fn(async (requested: ProjectTarget) => ({ + target: requested, + resolution: { kind: 'available' as const, effectiveProjectKey: '/real/project' }, + })); + const store = new ProjectResolutionStore(fetchResolution); + const initialLifecycleKey = store.lifecycleKey(target); + store.markObsoleteChatTargets(CHAT_ID, target.projectPath); + const boundLifecycleKey = store.lifecycleKey(target); + const lease = store.retain(target); + await lease.resolve(); + + store.removeChatTargets(CHAT_ID); + + expect(boundLifecycleKey).not.toBe(initialLifecycleKey); + expect(store.lifecycleKey(target)).toBe(initialLifecycleKey); + expect(lease.snapshot).toEqual({ kind: 'unchecked' }); + expect(store.snapshotFor(target)).toEqual({ kind: 'unchecked' }); + const replacement = store.retain(target); + expect(replacement.snapshot).toEqual({ kind: 'unchecked' }); + lease.release(); + replacement.release(); + }); + + it('does not let released leases start or supersede requests', async () => { + const fetchResolution = vi.fn(async (requested: ProjectTarget) => ({ + target: requested, + resolution: { kind: 'available' as const, effectiveProjectKey: '/real/project' }, + })); + const store = new ProjectResolutionStore(fetchResolution); + const lease = store.retain(target); + lease.release(); + + await expect(lease.resolve()).rejects.toThrow('Project resolution lease has been released'); + await expect(lease.retry()).rejects.toThrow('Project resolution lease has been released'); + expect(fetchResolution).not.toHaveBeenCalled(); + }); + + it('aborts every retained request when the owning workspace is destroyed', async () => { + const signals: AbortSignal[] = []; + const fetchResolution = vi.fn((_target, signal: AbortSignal) => { + signals.push(signal); + return new Promise(() => undefined); + }); + const store = new ProjectResolutionStore(fetchResolution); + const first = store.retain(target); + const second = store.retain({ ...target, projectPath: '/workspace/project-b' }); + void first.resolve(); + void second.resolve(); + + store.destroy(); + + expect(signals).toHaveLength(2); + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect(store.snapshotFor(target)).toEqual({ kind: 'unchecked' }); + await expect(first.resolve()).resolves.toBeUndefined(); + expect(() => store.retain(target)).toThrow('Project resolution store has been destroyed'); + }); + + it('invalidates chat metadata when the server reports a changed binding', async () => { + const onBindingChanged = vi.fn(); + const store = new ProjectResolutionStore(async () => { + throw new ApiError(409, 'changed', 'PROJECT_PATH_CHANGED'); + }, onBindingChanged); + const lease = store.retain(target); + + await lease.resolve(); + + expect(lease.snapshot).toEqual({ kind: 'request-failed', message: 'changed' }); + expect(onBindingChanged).toHaveBeenCalledWith(target); + lease.release(); + }); +}); diff --git a/web/src/lib/workspace/__tests__/workspace-coordinator.test.ts b/web/src/lib/workspace/__tests__/workspace-coordinator.test.ts index 1bb65bf08..e9ca6b543 100644 --- a/web/src/lib/workspace/__tests__/workspace-coordinator.test.ts +++ b/web/src/lib/workspace/__tests__/workspace-coordinator.test.ts @@ -27,6 +27,12 @@ import type { } from '../window-geometry-policy'; import { resolveUnmeasuredWorkspaceSplit } from './workspace-geometry-test-fixtures'; import { WorkspacePresentationController } from '../workspace-presentation-controller.svelte'; +import type { ProjectTarget } from '$shared/project-resolution'; +import type { + ProjectResolutionLease, + ProjectResolutionSnapshot, +} from '../project-resolution-store.svelte'; +import type { ProjectResolver } from '../workspace-project-path-resolution'; function deferred() { let resolve!: (value: T) => void; @@ -79,6 +85,8 @@ function createHarness( includePortableTabs?: boolean; resolveSplitAdmission?: WorkspaceSplitAdmissionResolver; resolvePartitionRatioBounds?: WorkspacePartitionRatioBoundsResolver; + currentProjectTarget?: ProjectTarget | null; + projectResolution?: ProjectResolver; } = {}, ) { const layout = createWorkspaceLayoutStore(); @@ -175,7 +183,12 @@ function createHarness( const coordinator = new WorkspaceCoordinator({ arbiter: new WorkspaceTransitionArbiter(layout, commitPort), terminals: terminals as never, - workspaceContext: { current: null } as never, + workspaceContext: { + get currentTarget() { + return options.currentProjectTarget ?? null; + }, + } as never, + projectResolution: options.projectResolution ?? ({ retain: vi.fn() } as never), appShell: appShell as never, workspaceInteractionGate, transientLayers, @@ -1600,6 +1613,18 @@ describe('WorkspaceCoordinator', () => { expect(coordinator.composerAnchorSurfaceId).toBe(CANONICAL_CHAT_SURFACE_ID); }); + it('advances focus ownership only when the focused surface identity changes', () => { + const { coordinator } = createHarness(); + const initialRevision = coordinator.focusOwnerRevision; + + coordinator.noteSurfaceFocus(CANONICAL_CHAT_SURFACE_ID); + expect(coordinator.focusOwnerRevision).toBe(initialRevision); + + coordinator.noteChatListFocus(); + coordinator.noteSurfaceFocus(CANONICAL_CHAT_SURFACE_ID); + expect(coordinator.focusOwnerRevision).toBe(initialRevision + 2); + }); + it('updates command ownership on pointerdown and defers Chat anchoring until click', () => { const { coordinator, layout } = createHarness(); layout.publish( @@ -2266,6 +2291,106 @@ describe('WorkspaceCoordinator', () => { ); }); + it('coalesces a keyed terminal create while project resolution is pending', async () => { + const target = { + kind: 'chat' as const, + chatId: 'chat-1', + projectPath: '/workspace', + }; + const projectReady = deferred(); + let snapshot: ProjectResolutionSnapshot = { kind: 'resolving' }; + const lease = { + target, + get snapshot() { + return snapshot; + }, + resolve: vi.fn(async () => { + await projectReady.promise; + snapshot = { kind: 'available', effectiveProjectKey: '/workspace' }; + }), + retry: vi.fn(), + release: vi.fn(), + } satisfies ProjectResolutionLease; + const projectResolution = { retain: vi.fn(() => lease) } satisfies ProjectResolver; + const { coordinator, terminals, layout } = createHarness({ + currentProjectTarget: target, + projectResolution, + }); + terminals.create.mockResolvedValue('terminal-coalesced'); + + const first = coordinator.createTerminal('window-main', 'workspace-window:window-main'); + const second = coordinator.createTerminal('window-main', 'workspace-window:window-main'); + await vi.waitFor(() => expect(lease.resolve).toHaveBeenCalledOnce()); + expect(projectResolution.retain).toHaveBeenCalledOnce(); + expect(terminals.create).not.toHaveBeenCalled(); + + projectReady.resolve(); + await expect(Promise.all([first, second])).resolves.toEqual([ + 'terminal-coalesced', + 'terminal-coalesced', + ]); + + expect(terminals.create).toHaveBeenCalledOnce(); + expect(lease.release).toHaveBeenCalledOnce(); + expect(windowTabs(layout.snapshot, 'window-main').order).toContain( + terminalSurfaceId('terminal-coalesced'), + ); + }); + + it('retries an ambiguous terminal create with its captured project path', async () => { + const target = { + kind: 'chat' as const, + chatId: 'chat-1', + projectPath: '/workspace/project-a', + }; + const lease = { + target, + snapshot: { + kind: 'available' as const, + effectiveProjectKey: '/workspace/project-a', + }, + resolve: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('unavailable')), + retry: vi.fn(), + release: vi.fn(), + } satisfies ProjectResolutionLease; + const projectResolution = { retain: vi.fn(() => lease) } satisfies ProjectResolver; + const { coordinator, terminals } = createHarness({ + currentProjectTarget: target, + projectResolution, + }); + const requests: Array<{ projectPath: string | null; requestId: string }> = []; + terminals.create + .mockImplementationOnce(async (projectPath: string | null, requestId: string) => { + requests.push({ projectPath, requestId }); + terminals.pendingCreates[requestId] = { + requestedInitialWorkingDirectory: projectPath, + }; + throw new TypeError('Lost terminal response'); + }) + .mockImplementationOnce(async (projectPath: string | null, requestId: string) => { + requests.push({ projectPath, requestId }); + delete terminals.pendingCreates[requestId]; + return 'terminal-recovered'; + }); + + await expect( + coordinator.createTerminal('window-main', 'workspace-window:window-main'), + ).rejects.toThrow('Lost terminal response'); + await expect( + coordinator.createTerminal('window-main', 'workspace-window:window-main'), + ).resolves.toBe('terminal-recovered'); + + expect(requests).toEqual([ + { projectPath: '/workspace/project-a', requestId: requests[0]?.requestId }, + { projectPath: '/workspace/project-a', requestId: requests[0]?.requestId }, + ]); + expect(projectResolution.retain).toHaveBeenCalledOnce(); + expect(lease.resolve).toHaveBeenCalledOnce(); + }); + it.each(['current window', 'new window'] as const)( 'keeps a terminal session when its %s surface closes before presentation settles', async (destination) => { diff --git a/web/src/lib/workspace/__tests__/workspace-layout.test.ts b/web/src/lib/workspace/__tests__/workspace-layout.test.ts index 392ba3a6f..991e69047 100644 --- a/web/src/lib/workspace/__tests__/workspace-layout.test.ts +++ b/web/src/lib/workspace/__tests__/workspace-layout.test.ts @@ -289,7 +289,7 @@ describe('workspace layout reducer', () => { invokerSurfaceId: sourceSurfaceId, invokerHost: 'window-source', chatId: 'chat-a', - effectiveProjectKey: null, + projectPath: null, routeIdentity: '/chat/chat-a', }, ], diff --git a/web/src/lib/workspace/__tests__/workspace-services.test.ts b/web/src/lib/workspace/__tests__/workspace-services.test.ts index 1f6250cf2..5185fe53b 100644 --- a/web/src/lib/workspace/__tests__/workspace-services.test.ts +++ b/web/src/lib/workspace/__tests__/workspace-services.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { tick } from 'svelte'; +import { ApiError } from '$lib/api/client.js'; import { createAppShellStore } from '$lib/stores/app-shell.svelte.js'; import { createChatSessionsStore } from '$lib/chat/sessions/chat-sessions.svelte.js'; import { createGhCapabilityStore } from '$lib/stores/gh-capability.svelte.js'; @@ -11,6 +12,8 @@ import { createModelCatalogStore } from '$lib/agents/model-catalog-store.svelte. import { createNavigationStore } from '$lib/stores/navigation.svelte.js'; import { createNotificationsStore } from '$lib/stores/notifications.svelte.js'; import type { PrimaryWsConnectionPort } from '$lib/ws/connection.svelte.js'; +import type { ChatListEntry } from '$shared/chat-list'; +import type { ProjectTarget } from '$shared/project-resolution'; import type { WorkspaceWindowId } from '$lib/workspace/surface-types.js'; import { windowIdOfSurface, windowNodeById } from '../window-tree.js'; import { @@ -50,14 +53,53 @@ vi.mock('$lib/api/files.js', async (importOriginal) => { }; }); +const projectResolutionApiMocks = vi.hoisted(() => ({ resolveProject: vi.fn() })); + +vi.mock('$lib/api/project-resolution.js', () => ({ + resolveProject: projectResolutionApiMocks.resolveProject, +})); + const DEFAULT_WINDOW: WorkspaceWindowId = 'window-main'; const OTHER_WINDOW: WorkspaceWindowId = 'window-2'; +function makeChatEntry(overrides: Partial = {}): ChatListEntry { + return { + id: '1788698026082000', + parentChat: null, + agentId: 'codex', + agentOwnershipEpoch: 'epoch-1', + model: 'default', + permissionMode: 'default', + thinkingMode: 'none', + agentSettings: { ownerId: 'codex', schemaVersion: 1, values: {} }, + title: 'Project chat', + projectPath: '/workspace/project', + orderGroup: 'normal', + tags: [], + activity: { + createdAt: '2026-09-06T00:00:00.000Z', + lastActivityAt: '2026-09-06T00:00:00.000Z', + lastReadAt: '2026-09-06T00:00:00.000Z', + }, + preview: { lastMessage: 'Initial preview' }, + isPinned: false, + isArchived: false, + isActive: false, + isProcessing: false, + processingPhase: null, + canReloadFromNativeHistory: false, + isUnread: false, + ...overrides, + }; +} + function assembleWorkspaceServices(localSettings: LocalSettingsStore): { services: WorkspaceServices; ghCapability: ReturnType; + chatSessions: ReturnType; } { const ghCapability = createGhCapabilityStore(); + const chatSessions = createChatSessionsStore(); ghCapability.hasChecked = true; ghCapability.available = true; const ws = { @@ -69,7 +111,7 @@ function assembleWorkspaceServices(localSettings: LocalSettingsStore): { return { services: createWorkspaceServices({ appShell: createAppShellStore(), - chatSessions: createChatSessionsStore(), + chatSessions, ghCapability, localSettings, modelCatalog: createModelCatalogStore(), @@ -83,6 +125,7 @@ function assembleWorkspaceServices(localSettings: LocalSettingsStore): { workspaceLayoutRaw: null, }), ghCapability, + chatSessions, }; } @@ -95,6 +138,7 @@ describe('createWorkspaceServices', () => { services = null; rootLocalSettings?.destroy(); rootLocalSettings = null; + projectResolutionApiMocks.resolveProject.mockReset(); }); it.each([ @@ -217,6 +261,182 @@ describe('createWorkspaceServices', () => { expect(services.singletonSurfaces.pullRequests().capabilityState).toBe('unavailable'); }); + it('does not resolve the selected project again for record-only chat updates', async () => { + projectResolutionApiMocks.resolveProject.mockImplementation(async (target: ProjectTarget) => ({ + target, + resolution: { kind: 'available' as const, effectiveProjectKey: target.projectPath }, + })); + rootLocalSettings = createLocalSettingsStore(); + rootLocalSettings.showQuickCommitTray = false; + const assembled = assembleWorkspaceServices(rootLocalSettings); + services = assembled.services; + const entry = makeChatEntry(); + assembled.chatSessions.upsertServerChat(entry); + assembled.chatSessions.setSelectedChatId(entry.id); + await vi.waitFor(() => expect(projectResolutionApiMocks.resolveProject).toHaveBeenCalledOnce()); + assembled.chatSessions.patchPreview(entry.id, 'Streaming preview'); + assembled.chatSessions.patchActivity(entry.id, '2026-09-06T00:00:01.000Z'); + assembled.chatSessions.applyProcessingEvent(entry.id, 'running'); + await tick(); + + expect(projectResolutionApiMocks.resolveProject).toHaveBeenCalledOnce(); + }); + + it('disposes retained project resolution when its chat is removed', async () => { + let resolutionSignal: AbortSignal | undefined; + projectResolutionApiMocks.resolveProject.mockImplementation( + (_target: ProjectTarget, signal: AbortSignal) => { + resolutionSignal = signal; + return new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }, + ); + rootLocalSettings = createLocalSettingsStore(); + const assembled = assembleWorkspaceServices(rootLocalSettings); + services = assembled.services; + const entry = makeChatEntry(); + assembled.chatSessions.upsertServerChat(entry); + const lease = services.projectResolution.retain({ + kind: 'chat', + chatId: entry.id, + projectPath: entry.projectPath, + }); + const resolution = lease.resolve(); + await vi.waitFor(() => expect(resolutionSignal).toBeDefined()); + + assembled.chatSessions.removeChat(entry.id); + expect(resolutionSignal?.aborted).toBe(true); + await resolution; + + expect(lease.snapshot).toEqual({ kind: 'unchecked' }); + lease.release(); + }); + + it('renews demanded resolution after an A/B/A binding change in one reactive flush', async () => { + projectResolutionApiMocks.resolveProject.mockImplementation(async (target: ProjectTarget) => ({ + target, + resolution: { kind: 'available' as const, effectiveProjectKey: target.projectPath }, + })); + rootLocalSettings = createLocalSettingsStore(); + rootLocalSettings.showQuickCommitTray = true; + const assembled = assembleWorkspaceServices(rootLocalSettings); + services = assembled.services; + const entry = makeChatEntry({ projectPath: '/workspace/a' }); + assembled.chatSessions.upsertServerChat(entry); + assembled.chatSessions.setSelectedChatId(entry.id); + await vi.waitFor(() => expect(projectResolutionApiMocks.resolveProject).toHaveBeenCalledOnce()); + + assembled.chatSessions.patchChat(entry.id, { projectPath: '/workspace/b' }); + assembled.chatSessions.patchChat(entry.id, { projectPath: '/workspace/a' }); + + await vi.waitFor(() => + expect(projectResolutionApiMocks.resolveProject).toHaveBeenCalledTimes(2), + ); + expect( + projectResolutionApiMocks.resolveProject.mock.calls.map( + ([requested]) => requested.projectPath, + ), + ).toEqual(['/workspace/a', '/workspace/a']); + }); + + it('keeps a resolved destination when an old binding requests a metadata refresh', async () => { + const oldTarget = { + kind: 'chat', + chatId: '1788698026082000', + projectPath: '/workspace/old-project', + } as const; + const destination = { ...oldTarget, projectPath: '/workspace/new-project' } as const; + const oldResult = Promise.withResolvers(); + projectResolutionApiMocks.resolveProject.mockImplementation( + async (requested: ProjectTarget) => { + if (requested.projectPath === oldTarget.projectPath) return oldResult.promise; + return { + target: requested, + resolution: { + kind: 'available' as const, + effectiveProjectKey: '/real/new-project', + }, + }; + }, + ); + rootLocalSettings = createLocalSettingsStore(); + const assembled = assembleWorkspaceServices(rootLocalSettings); + services = assembled.services; + assembled.chatSessions.upsertServerChat(makeChatEntry({ projectPath: oldTarget.projectPath })); + const refresh = vi + .spyOn(assembled.chatSessions, 'quietRefreshChats') + .mockResolvedValue(undefined); + const oldLease = services.projectResolution.retain(oldTarget); + const destinationLease = services.projectResolution.retain(destination); + const oldPending = oldLease.resolve(); + await destinationLease.resolve(); + + oldResult.reject(new ApiError(409, 'changed', 'PROJECT_PATH_CHANGED')); + await oldPending; + + expect(oldLease.snapshot).toEqual({ kind: 'request-failed', message: 'changed' }); + expect(destinationLease.snapshot).toEqual({ + kind: 'available', + effectiveProjectKey: '/real/new-project', + }); + expect(refresh).toHaveBeenCalledOnce(); + oldLease.release(); + destinationLease.release(); + }); + + it('skips binding refresh after the declared path has already changed', async () => { + projectResolutionApiMocks.resolveProject.mockRejectedValue( + new ApiError(409, 'changed', 'PROJECT_PATH_CHANGED'), + ); + rootLocalSettings = createLocalSettingsStore(); + const assembled = assembleWorkspaceServices(rootLocalSettings); + services = assembled.services; + assembled.chatSessions.upsertServerChat(makeChatEntry({ projectPath: '/workspace/new' })); + const refresh = vi.spyOn(assembled.chatSessions, 'quietRefreshChats'); + const lease = services.projectResolution.retain({ + kind: 'chat', + chatId: '1788698026082000', + projectPath: '/workspace/old', + }); + + await lease.resolve(); + + expect(refresh).not.toHaveBeenCalled(); + lease.release(); + }); + + it('coalesces binding refreshes while reconciliation is pending', async () => { + projectResolutionApiMocks.resolveProject.mockRejectedValue( + new ApiError(409, 'changed', 'PROJECT_PATH_CHANGED'), + ); + rootLocalSettings = createLocalSettingsStore(); + const assembled = assembleWorkspaceServices(rootLocalSettings); + services = assembled.services; + assembled.chatSessions.upsertServerChat(makeChatEntry({ projectPath: '/workspace/project' })); + const pendingRefresh = Promise.withResolvers(); + const refresh = vi + .spyOn(assembled.chatSessions, 'quietRefreshChats') + .mockReturnValue(pendingRefresh.promise); + const lease = services.projectResolution.retain({ + kind: 'chat', + chatId: '1788698026082000', + projectPath: '/workspace/project', + }); + + await lease.resolve(); + await lease.retry(); + + expect(refresh).toHaveBeenCalledOnce(); + pendingRefresh.resolve(); + await pendingRefresh.promise; + lease.release(); + }); + it('resolves partition bounds from the shared host measurement', async () => { rootLocalSettings = createLocalSettingsStore(); ({ services } = assembleWorkspaceServices(rootLocalSettings)); diff --git a/web/src/lib/workspace/mobile-presentation-planner.ts b/web/src/lib/workspace/mobile-presentation-planner.ts index 3e0959ca6..3b7d68bfb 100644 --- a/web/src/lib/workspace/mobile-presentation-planner.ts +++ b/web/src/lib/workspace/mobile-presentation-planner.ts @@ -8,7 +8,7 @@ import { collectWindowNodes } from './window-tree.js'; interface MobileWorkspaceContext { chatId: string; - effectiveProjectKey: string | null; + projectPath: string; } interface MobilePresentationPlannerDeps { @@ -41,7 +41,7 @@ export class MobilePresentationPlanner { invokerSurfaceId: snapshot.mobileActiveSurfaceId, invokerHost: 'mobile', chatId: context?.chatId ?? null, - effectiveProjectKey: context?.effectiveProjectKey ?? null, + projectPath: context?.projectPath ?? null, routeIdentity: this.deps.getRouteIdentity(), }, ]; @@ -65,7 +65,7 @@ export class MobilePresentationPlanner { snapshot.surfaces[target.invokerSurfaceId] && target.routeIdentity === routeIdentity && target.chatId === (context?.chatId ?? null) && - target.effectiveProjectKey === (context?.effectiveProjectKey ?? null) + target.projectPath === (context?.projectPath ?? null) ) { return { activeId: target.invokerSurfaceId, diff --git a/web/src/lib/workspace/project-resolution-store.svelte.ts b/web/src/lib/workspace/project-resolution-store.svelte.ts new file mode 100644 index 000000000..df9ae3ebc --- /dev/null +++ b/web/src/lib/workspace/project-resolution-store.svelte.ts @@ -0,0 +1,223 @@ +import { SvelteMap } from 'svelte/reactivity'; +import { + projectTargetKey, + type ProjectResolution, + type ProjectTarget, +} from '$shared/project-resolution'; +import { ApiError } from '$lib/api/client.js'; +import { resolveProject } from '$lib/api/project-resolution.js'; + +export type ProjectResolutionSnapshot = + | { readonly kind: 'unchecked' } + | { readonly kind: 'resolving' } + | ProjectResolution + | { readonly kind: 'request-failed'; readonly message: string }; + +export interface ProjectResolutionLease { + readonly target: ProjectTarget; + readonly snapshot: ProjectResolutionSnapshot; + resolve(): Promise; + retry(): Promise; + release(): void; +} + +interface RetainedRecord { + record: ProjectResolutionRecord; + references: number; +} + +interface PendingResolution { + controller: AbortController; + completion: Promise; + waiter: Promise; +} + +interface ChatBinding { + projectPath: string; + revision: number; +} + +class ProjectResolutionRecord { + snapshot = $state({ kind: 'unchecked' }); + #request: PendingResolution | null = null; + #disposed = false; + + constructor( + readonly target: ProjectTarget, + private readonly fetchResolution: typeof resolveProject, + private readonly isRetained: () => boolean, + private readonly onBindingChanged: (target: Extract) => void, + ) {} + + resolve(): Promise { + if (this.#disposed) return Promise.resolve(); + if (this.#request) return this.#request.waiter; + const controller = new AbortController(); + if (this.snapshot.kind === 'unchecked') this.snapshot = { kind: 'resolving' }; + const pending: PendingResolution = { + controller, + completion: Promise.resolve(), + waiter: Promise.resolve(), + }; + this.#request = pending; + const isCurrent = () => + this.#request === pending && !controller.signal.aborted && this.isRetained(); + let request: ReturnType; + try { + request = this.fetchResolution(this.target, controller.signal); + } catch (error) { + request = Promise.reject(error); + } + pending.completion = request + .then((response) => { + if (isCurrent()) this.snapshot = response.resolution; + }) + .catch((error: unknown) => { + if (!isCurrent()) return; + this.snapshot = { + kind: 'request-failed', + message: error instanceof Error ? error.message : 'Project check failed', + }; + if ( + error instanceof ApiError && + error.errorCode === 'PROJECT_PATH_CHANGED' && + this.target.kind === 'chat' + ) { + this.onBindingChanged(this.target); + } + }) + .finally(() => { + if (this.#request === pending) this.#request = null; + }); + pending.waiter = this.#waitForCurrentRequest(pending); + return pending.waiter; + } + + retry(): Promise { + if (this.#disposed) return Promise.resolve(); + const previous = this.#request; + this.#request = null; + previous?.controller.abort(); + this.snapshot = { kind: 'unchecked' }; + return this.resolve(); + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#request?.controller.abort(); + this.#request = null; + this.snapshot = { kind: 'unchecked' }; + } + + async #waitForCurrentRequest(request: PendingResolution): Promise { + await request.completion; + if (this.#disposed) return; + const current = this.#request; + if (current && current !== request) await current.waiter; + } +} + +export class ProjectResolutionStore { + readonly #records = new SvelteMap(); + readonly #chatBindings = new SvelteMap(); + #destroyed = false; + + constructor( + private readonly fetchResolution: typeof resolveProject = resolveProject, + private readonly onBindingChanged: ( + target: Extract, + ) => void = () => undefined, + ) {} + + retain(target: ProjectTarget): ProjectResolutionLease { + if (this.#destroyed) throw new Error('Project resolution store has been destroyed'); + const key = projectTargetKey(target); + let retained = this.#records.get(key); + if (!retained) { + const record = new ProjectResolutionRecord( + target, + this.fetchResolution, + (): boolean => this.#records.get(key)?.record === record, + this.onBindingChanged, + ); + retained = { record, references: 0 }; + this.#records.set(key, retained); + } + retained.references += 1; + let released = false; + const record = retained.record; + return { + target: record.target, + get snapshot() { + return record.snapshot; + }, + resolve: () => + released + ? Promise.reject(new Error('Project resolution lease has been released')) + : record.resolve(), + retry: () => + released + ? Promise.reject(new Error('Project resolution lease has been released')) + : record.retry(), + release: () => { + if (released) return; + released = true; + const current = this.#records.get(key); + if (!current || current.record !== record) return; + current.references -= 1; + if (current.references > 0) return; + record.dispose(); + this.#records.delete(key); + }, + }; + } + + snapshotFor(target: ProjectTarget): ProjectResolutionSnapshot { + return this.#records.get(projectTargetKey(target))?.record.snapshot ?? { kind: 'unchecked' }; + } + + lifecycleKey(target: ProjectTarget): string { + const key = projectTargetKey(target); + if (target.kind === 'path') return key; + return `${key}\u0000${this.#chatBindings.get(target.chatId)?.revision ?? 0}`; + } + + markObsoleteChatTargets(chatId: string, currentProjectPath: string): void { + const binding = this.#chatBindings.get(chatId); + if (binding?.projectPath === currentProjectPath) return; + this.#chatBindings.set(chatId, { + projectPath: currentProjectPath, + revision: (binding?.revision ?? 0) + 1, + }); + for (const [key, retained] of this.#records) { + const target = retained.record.target; + if ( + target.kind !== 'chat' || + target.chatId !== chatId || + target.projectPath === currentProjectPath + ) + continue; + retained.record.dispose(); + this.#records.delete(key); + } + } + + removeChatTargets(chatId: string): void { + this.#chatBindings.delete(chatId); + for (const [key, retained] of this.#records) { + const target = retained.record.target; + if (target.kind !== 'chat' || target.chatId !== chatId) continue; + retained.record.dispose(); + this.#records.delete(key); + } + } + + destroy(): void { + if (this.#destroyed) return; + this.#destroyed = true; + for (const retained of this.#records.values()) retained.record.dispose(); + this.#records.clear(); + this.#chatBindings.clear(); + } +} diff --git a/web/src/lib/workspace/singleton-surfaces.svelte.ts b/web/src/lib/workspace/singleton-surfaces.svelte.ts index 06c3dfd62..5c47edeb8 100644 --- a/web/src/lib/workspace/singleton-surfaces.svelte.ts +++ b/web/src/lib/workspace/singleton-surfaces.svelte.ts @@ -1,4 +1,7 @@ -import type { PortableSingletonKind } from '$lib/workspace/surface-types.js'; +import { + PORTABLE_SINGLETON_KINDS, + type PortableSingletonKind, +} from '$lib/workspace/surface-types.js'; import type { PortableSingletonController } from '$lib/workspace/portable-singleton-controller.js'; import { FileTreeStore } from '$lib/files/tree/file-tree.svelte.js'; import type { GitSurfaceControllerDeps } from '$lib/git/surface/git-surface-controller-deps.js'; @@ -74,6 +77,7 @@ export class SingletonSurfaceRegistry { commit: false, 'chat-map': false, }; + #hasVisibleProjectSurface = $state(false); constructor(private readonly deps: SingletonSurfaceRegistryDeps) { this.#factories = { @@ -143,11 +147,17 @@ export class SingletonSurfaceRegistry { setPresentationVisible(kind: PortableSingletonKind, visible: boolean): void { if (this.#visible[kind] === visible) return; this.#visible[kind] = visible; + this.#updateVisibleProjectSurface(); this.#controllers.get(kind)?.controller.setPresentationVisible(visible); } + get hasVisibleProjectSurface(): boolean { + return this.#hasVisibleProjectSurface; + } + disposeSurface(kind: PortableSingletonKind): void { this.#visible[kind] = false; + this.#updateVisibleProjectSurface(); const owned = this.#controllers.get(kind); if (!owned) return; this.#controllers.delete(kind); @@ -159,6 +169,12 @@ export class SingletonSurfaceRegistry { } } + #updateVisibleProjectSurface(): void { + this.#hasVisibleProjectSurface = PORTABLE_SINGLETON_KINDS.some( + (candidate) => candidate !== 'chat-map' && this.#visible[candidate], + ); + } + destroy(): void { for (const kind of [...this.#controllers.keys()]) this.disposeSurface(kind); } diff --git a/web/src/lib/workspace/surface-types.ts b/web/src/lib/workspace/surface-types.ts index af6d60362..ba26bd0dc 100644 --- a/web/src/lib/workspace/surface-types.ts +++ b/web/src/lib/workspace/surface-types.ts @@ -86,7 +86,7 @@ export interface MobileReturnTarget { invokerSurfaceId: string; invokerHost: WorkspaceWindowId | 'mobile'; chatId: string | null; - effectiveProjectKey: string | null; + projectPath: string | null; routeIdentity: string; } diff --git a/web/src/lib/workspace/terminal-placement-service.ts b/web/src/lib/workspace/terminal-placement-service.ts index c824eadd6..6d14e1fa4 100644 --- a/web/src/lib/workspace/terminal-placement-service.ts +++ b/web/src/lib/workspace/terminal-placement-service.ts @@ -45,7 +45,7 @@ interface TerminalPlacementServiceDeps { isWindowReserved(windowId: WorkspaceWindowId): boolean; commit: WorkspaceCommit; commitDestroyedRemoval(surfaceId: string, mutations: WorkspaceMutationPlan): Promise; - currentProjectPath(): string | null; + resolveCurrentProjectPath(): Promise; isMobile(): boolean; cancelWorkspaceDrag(): void; windowOf(surfaceId: string): WorkspaceWindowId | null; @@ -66,6 +66,7 @@ interface TerminalPlacementServiceDeps { export class TerminalPlacementService { #terminalCreateRequestIds = new Map(); + #terminalCreatePromises = new Map>(); #terminalTerminateRequestIds = new Map(); #pendingTerminatedTerminalIds = new Set(); @@ -612,19 +613,40 @@ export class TerminalPlacementService { } #hasPendingCreate(requestKey: string): boolean { + if (this.#terminalCreatePromises.has(requestKey)) return true; const requestId = this.#terminalCreateRequestIds.get(requestKey); return Boolean(requestId && this.deps.terminals.pendingCreates[requestId]); } - async #retryCreate(requestKey: string): Promise { + #retryCreate(requestKey: string): Promise { + const pending = this.#terminalCreatePromises.get(requestKey); + if (pending) return pending; + const creating = this.#performRetriableCreate(requestKey); + const tracked = creating.finally(() => { + if (this.#terminalCreatePromises.get(requestKey) === tracked) { + this.#terminalCreatePromises.delete(requestKey); + } + }); + this.#terminalCreatePromises.set(requestKey, tracked); + return tracked; + } + + async #performRetriableCreate(requestKey: string): Promise { let requestId = this.#terminalCreateRequestIds.get(requestKey); - if (requestId && !this.deps.terminals.pendingCreates[requestId]) { + const attempt = requestId ? this.deps.terminals.pendingCreates[requestId] : undefined; + if (requestId && !attempt) { this.#terminalCreateRequestIds.delete(requestKey); requestId = undefined; } requestId ??= createRandomId(); this.#terminalCreateRequestIds.set(requestKey, requestId); try { + if (attempt) { + return await this.deps.terminals.create( + attempt.requestedInitialWorkingDirectory, + requestId, + ); + } return await this.#createWithRequestId(requestId); } finally { if (!this.deps.terminals.pendingCreates[requestId]) { @@ -633,8 +655,9 @@ export class TerminalPlacementService { } } - #createWithRequestId(requestId: string): Promise { - return this.deps.terminals.create(this.deps.currentProjectPath(), requestId); + async #createWithRequestId(requestId: string): Promise { + const projectPath = await this.deps.resolveCurrentProjectPath(); + return this.deps.terminals.create(projectPath, requestId); } #placementResolved(terminalId: string): boolean { diff --git a/web/src/lib/workspace/workspace-context.svelte.ts b/web/src/lib/workspace/workspace-context.svelte.ts index 68092a9da..20d15bbf8 100644 --- a/web/src/lib/workspace/workspace-context.svelte.ts +++ b/web/src/lib/workspace/workspace-context.svelte.ts @@ -1,10 +1,11 @@ import type { ChatSessionsStore } from '$lib/chat/sessions/chat-sessions.svelte.js'; import type { ModelCatalogStore } from '$lib/agents/model-catalog-store.svelte'; +import type { ProjectTarget, ProjectUnavailableReason } from '$shared/project-resolution'; +import type { ProjectResolutionStore } from './project-resolution-store.svelte.js'; export interface WorkspaceContext { chatId: string; projectPath: string; - effectiveProjectKey: string | null; } export interface AvailableWorkspaceProject extends WorkspaceContext { @@ -13,13 +14,17 @@ export interface AvailableWorkspaceProject extends WorkspaceContext { export type WorkspaceProjectState = | { kind: 'absent' } + | { kind: 'unchecked'; context: WorkspaceContext } | { kind: 'resolving'; context: WorkspaceContext } + | { kind: 'unavailable'; context: WorkspaceContext; reason: ProjectUnavailableReason } + | { kind: 'request-failed'; context: WorkspaceContext; message: string } | { kind: 'available'; project: AvailableWorkspaceProject }; export class WorkspaceContextStore { constructor( private readonly sessions: Pick, private readonly modelCatalog: Pick, + private readonly projectResolution: Pick, ) {} get current(): WorkspaceContext | null { @@ -28,21 +33,45 @@ export class WorkspaceContextStore { return { chatId: chat.id, projectPath: chat.projectPath, - effectiveProjectKey: chat.effectiveProjectKey, }; } get currentProject(): AvailableWorkspaceProject | null { const current = this.current; - if (!current?.effectiveProjectKey) return null; - return { ...current, effectiveProjectKey: current.effectiveProjectKey }; + const target = this.currentTarget; + if (!current || !target) return null; + const resolution = this.projectResolution.snapshotFor(target); + if (resolution.kind !== 'available') return null; + return { ...current, effectiveProjectKey: resolution.effectiveProjectKey }; } get projectState(): WorkspaceProjectState { const current = this.current; - if (!current) return { kind: 'absent' }; - const project = this.currentProject; - return project ? { kind: 'available', project } : { kind: 'resolving', context: current }; + const target = this.currentTarget; + if (!current || !target) return { kind: 'absent' }; + const resolution = this.projectResolution.snapshotFor(target); + switch (resolution.kind) { + case 'available': + return { + kind: 'available', + project: { ...current, effectiveProjectKey: resolution.effectiveProjectKey }, + }; + case 'unavailable': + return { kind: 'unavailable', context: current, reason: resolution.reason }; + case 'request-failed': + return { kind: 'request-failed', context: current, message: resolution.message }; + case 'unchecked': + case 'resolving': + return { kind: resolution.kind, context: current }; + } + } + + get currentTarget(): ProjectTarget | null { + const chat = this.sessions.selectedChat; + if (!chat) return null; + return chat.status === 'draft' + ? { kind: 'path', projectPath: chat.projectPath } + : { kind: 'chat', chatId: chat.id, projectPath: chat.projectPath }; } get canUpdateProjectPath(): boolean { @@ -54,6 +83,7 @@ export class WorkspaceContextStore { export function createWorkspaceContextStore( sessions: Pick, modelCatalog: Pick, + projectResolution: Pick, ): WorkspaceContextStore { - return new WorkspaceContextStore(sessions, modelCatalog); + return new WorkspaceContextStore(sessions, modelCatalog, projectResolution); } diff --git a/web/src/lib/workspace/workspace-coordinator.svelte.ts b/web/src/lib/workspace/workspace-coordinator.svelte.ts index 52f27c71b..7d0c483a2 100644 --- a/web/src/lib/workspace/workspace-coordinator.svelte.ts +++ b/web/src/lib/workspace/workspace-coordinator.svelte.ts @@ -2,6 +2,7 @@ import type { AppShellStore } from '$lib/stores/app-shell.svelte.js'; import { SvelteSet } from 'svelte/reactivity'; import type { TerminalRegistry } from '$lib/terminal/sessions/terminal-registry.svelte.js'; import type { WorkspaceContextStore } from './workspace-context.svelte.js'; +import { resolveProjectPath, type ProjectResolver } from './workspace-project-path-resolution.js'; import { chatViewSurfaceId, fileSurfaceId, @@ -64,6 +65,7 @@ interface WorkspaceCoordinatorDeps { arbiter: WorkspaceTransitionArbiter; terminals: TerminalRegistry; workspaceContext: WorkspaceContextStore; + projectResolution: ProjectResolver; appShell: AppShellStore; workspaceInteractionGate: WorkspaceInteractionGate; transientLayers: TransientLayerRegistry; @@ -152,7 +154,7 @@ export class WorkspaceCoordinator implements FilePlacementPort { commit, commitDestroyedRemoval: (surfaceId, mutations) => this.#presentation.commitDestroyedRemovals([surfaceId], mutations), - currentProjectPath: () => deps.workspaceContext.current?.projectPath ?? null, + resolveCurrentProjectPath: () => resolveProjectPath(deps), isMobile: () => this.isMobile, cancelWorkspaceDrag: () => deps.workspaceInteractionGate.cancelBeforeInertTransition(), windowOf: (surfaceId) => this.#presentation.windowOf(surfaceId), @@ -205,6 +207,10 @@ export class WorkspaceCoordinator implements FilePlacementPort { this.#presentation.focusOwner = owner; } + get focusOwnerRevision(): number { + return this.#presentation.focusOwnerRevision; + } + get isMobile(): boolean { return this.#presentation.isMobile; } diff --git a/web/src/lib/workspace/workspace-domain-bindings.svelte.ts b/web/src/lib/workspace/workspace-domain-bindings.svelte.ts index 5ad3dc5ad..471f6054c 100644 --- a/web/src/lib/workspace/workspace-domain-bindings.svelte.ts +++ b/web/src/lib/workspace/workspace-domain-bindings.svelte.ts @@ -6,9 +6,14 @@ import type { GitQuickSummaryStore } from '$lib/git/surface/git-quick-summary.sv import type { LocalSettingsStore } from '$lib/stores/local-settings.svelte.js'; import type { SingletonSurfaceRegistry } from '$lib/workspace/singleton-surfaces.svelte.js'; import type { WorkspaceContextStore } from './workspace-context.svelte.js'; +import type { + ProjectResolutionLease, + ProjectResolutionStore, +} from './project-resolution-store.svelte.js'; interface WorkspaceDomainBindingsDeps { workspaceContext: WorkspaceContextStore; + projectResolution: ProjectResolutionStore; ghCapability: GhCapabilityStore; localSettings: LocalSettingsStore; singletons: SingletonSurfaceRegistry; @@ -23,6 +28,34 @@ export class WorkspaceDomainBindings { let lastCommitInvalidationKey = ''; // Bindings run for the application lifetime, so every sink tolerates absent pre-auth context. this.#destroyEffects = $effect.root(() => { + const currentTargetKey = $derived.by(() => { + const target = deps.workspaceContext.currentTarget; + return target ? deps.projectResolution.lifecycleKey(target) : null; + }); + + $effect(() => { + if (!currentTargetKey) return; + const target = untrack(() => deps.workspaceContext.currentTarget); + if (!target) return; + const lease = untrack(() => deps.projectResolution.retain(target)); + return () => lease.release(); + }); + + $effect(() => { + if (!currentTargetKey) return; + const hasDemand = + deps.singletons.hasVisibleProjectSurface || deps.localSettings.showQuickCommitTray; + if (!hasDemand) return; + const target = untrack(() => deps.workspaceContext.currentTarget); + if (!target) return; + const lease: ProjectResolutionLease = untrack(() => { + const retained = deps.projectResolution.retain(target); + void retained.resolve(); + return retained; + }); + return () => lease.release(); + }); + $effect(() => { deps.singletons.setProjectState(deps.workspaceContext.projectState); }); diff --git a/web/src/lib/workspace/workspace-presentation-controller.svelte.ts b/web/src/lib/workspace/workspace-presentation-controller.svelte.ts index efe9b5b75..8c1666036 100644 --- a/web/src/lib/workspace/workspace-presentation-controller.svelte.ts +++ b/web/src/lib/workspace/workspace-presentation-controller.svelte.ts @@ -75,10 +75,26 @@ function removeTransientMobileGitViews( })); } +export function sameFocusOwner(left: FocusOwner, right: FocusOwner): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === 'chat-list') return true; + if (right.kind === 'chat-list') return false; + if (left.kind === 'surface' && right.kind === 'surface') { + return left.surfaceId === right.surfaceId; + } + return ( + left.kind === 'window-chrome' && + right.kind === 'window-chrome' && + left.windowId === right.windowId && + left.surfaceId === right.surfaceId + ); +} + export class WorkspacePresentationController { lastFocusedSurfaceId = $state(''); lastFocusedWindowId = $state(null); - focusOwner = $state({ kind: 'chat-list' }); + #focusOwner = $state({ kind: 'chat-list' }); + #focusOwnerRevision = $state(0); composerAnchorSurfaceId = $state(null); #inFlightCommitCount = 0; #presentationMode = $state('desktop'); @@ -298,6 +314,20 @@ export class WorkspacePresentationController { }); } + get focusOwner(): FocusOwner { + return this.#focusOwner; + } + + set focusOwner(owner: FocusOwner) { + if (sameFocusOwner(this.#focusOwner, owner)) return; + this.#focusOwner = owner; + this.#focusOwnerRevision += 1; + } + + get focusOwnerRevision(): number { + return this.#focusOwnerRevision; + } + #beginWindowActivation( windowId: WorkspaceWindowId, ): { generation: number; surfaceId: string } | null { diff --git a/web/src/lib/workspace/workspace-project-path-resolution.ts b/web/src/lib/workspace/workspace-project-path-resolution.ts new file mode 100644 index 000000000..c9285bf9e --- /dev/null +++ b/web/src/lib/workspace/workspace-project-path-resolution.ts @@ -0,0 +1,25 @@ +import type { ProjectResolutionStore } from './project-resolution-store.svelte.js'; +import type { WorkspaceContextStore } from './workspace-context.svelte.js'; +import * as m from '$lib/paraglide/messages.js'; + +export type ProjectResolver = Pick; + +interface ProjectPathResolutionDeps { + workspaceContext: Pick; + projectResolution: ProjectResolver; +} + +export async function resolveProjectPath(deps: ProjectPathResolutionDeps): Promise { + const target = deps.workspaceContext.currentTarget; + if (!target) return null; + const lease = deps.projectResolution.retain(target); + try { + await lease.resolve(); + const snapshot = lease.snapshot; + if (snapshot.kind === 'available') return target.projectPath; + if (snapshot.kind === 'request-failed') throw new Error(snapshot.message); + throw new Error(m.workspace_project_unavailable()); + } finally { + lease.release(); + } +} diff --git a/web/src/lib/workspace/workspace-services.ts b/web/src/lib/workspace/workspace-services.ts index c8135ec7c..487a9e04a 100644 --- a/web/src/lib/workspace/workspace-services.ts +++ b/web/src/lib/workspace/workspace-services.ts @@ -38,6 +38,7 @@ import { WorkspaceShortcutDispatcher } from './workspace-shortcuts.js'; import { WorkspaceTransitionArbiter } from './workspace-transition-arbiter.js'; import { WorkspaceWindowDndController } from './window-dnd.svelte.js'; import { WorkspaceHostGeometryState } from './workspace-host-geometry.svelte.js'; +import { ProjectResolutionStore } from './project-resolution-store.svelte.js'; import { floorWorkspacePixels, resolveWorkspacePartitionRatioBounds, @@ -100,6 +101,7 @@ export interface WorkspaceServices { restore: ReturnType; layout: WorkspaceLayoutReader; context: ReturnType; + projectResolution: ProjectResolutionStore; terminals: TerminalRegistry; workspaceInteractionGate: WorkspaceInteractionGate; transientLayers: TransientLayerRegistry; @@ -134,7 +136,40 @@ export function createWorkspaceServices(deps: WorkspaceRootDependencies): Worksp }); }, }); - const context = createWorkspaceContextStore(deps.chatSessions, deps.modelCatalog); + const bindingRefreshes = new Map>(); + const projectResolution = new ProjectResolutionStore(undefined, (target) => { + if (deps.chatSessions.byId[target.chatId]?.projectPath !== target.projectPath) return; + if (bindingRefreshes.has(target.chatId)) return; + const refresh = (async () => { + try { + await deps.chatSessions.quietRefreshChats(); + } catch { + // Resolution feedback remains authoritative when metadata refresh fails. + } + })(); + bindingRefreshes.set(target.chatId, refresh); + void refresh.then(() => { + if (bindingRefreshes.get(target.chatId) === refresh) { + bindingRefreshes.delete(target.chatId); + } + }); + }); + const stopProjectPathBinding = deps.chatSessions.onProjectPathChanged( + (chatId, projectPath) => { + if (projectPath === null) projectResolution.removeChatTargets(chatId); + else projectResolution.markObsoleteChatTargets(chatId, projectPath); + }, + ); + for (const chat of deps.chatSessions.orderedChats) { + if (chat.status !== 'draft') { + projectResolution.markObsoleteChatTargets(chat.id, chat.projectPath); + } + } + const context = createWorkspaceContextStore( + deps.chatSessions, + deps.modelCatalog, + projectResolution, + ); let placement: WorkspaceCoordinator | null = null; let terminalLayoutBinding: TerminalLayoutBinding | null = null; const terminals = new TerminalRegistry({ @@ -262,6 +297,7 @@ export function createWorkspaceServices(deps: WorkspaceRootDependencies): Worksp }); const domainBindings = new WorkspaceDomainBindings({ workspaceContext: context, + projectResolution, ghCapability: deps.ghCapability, localSettings: deps.localSettings, singletons: singletonSurfaces, @@ -308,6 +344,7 @@ export function createWorkspaceServices(deps: WorkspaceRootDependencies): Worksp arbiter: new WorkspaceTransitionArbiter(layout, layout), terminals, workspaceContext: context, + projectResolution, appShell: deps.appShell, workspaceInteractionGate, transientLayers, @@ -350,6 +387,7 @@ export function createWorkspaceServices(deps: WorkspaceRootDependencies): Worksp restore, layout, context, + projectResolution, terminals, workspaceInteractionGate, transientLayers, @@ -375,6 +413,8 @@ export function createWorkspaceServices(deps: WorkspaceRootDependencies): Worksp singletonSurfaces.destroy(); gitQuickSummary.destroy(); gitBranchActions.destroy(); + stopProjectPathBinding(); + projectResolution.destroy(); persistence.destroy(); }, }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 07493f776..70e8be79d 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -53,6 +53,7 @@ import { createChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat- setSnippets, setWorkspaceLayout, setWorkspaceContext, + setProjectResolution, setTerminalRegistry, setWorkspaceCoordinator, setWorkspaceWindowDnd, @@ -140,6 +141,7 @@ import { createChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat- }); const workspaceLayout = workspaceServices.layout; const workspaceContext = workspaceServices.context; + const projectResolution = workspaceServices.projectResolution; const terminals = workspaceServices.terminals; const transientLayers = workspaceServices.transientLayers; const surfaceFrames = workspaceServices.surfaceFrames; @@ -179,6 +181,7 @@ import { createChatPreambleSelectionInvalidationHub } from '$lib/preambles/chat- setAppShell(appShell); setWorkspaceLayout(workspaceLayout); setWorkspaceContext(workspaceContext); + setProjectResolution(projectResolution); setTerminalRegistry(terminals); setWorkspaceCoordinator(workspace); setWorkspaceWindowDnd(workspaceServices.windowDnd);