From e1b2c6b24ff1b2d46c06d6a2fa6d2651498ed334 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 03:58:07 +0300 Subject: [PATCH 1/2] Fix release candidate regressions --- .../Layers/OrchestrationEngine.test.ts | 192 ++++++++++++++++++ .../Layers/OrchestrationEngine.ts | 35 +++- .../Layers/ProviderCommandReactor.test.ts | 133 +++++++++++- .../Layers/ProviderCommandReactor.ts | 2 +- apps/server/src/orchestration/messageFork.ts | 9 +- .../components/AddProjectDialog.browser.tsx | 165 ++++++++++++++- apps/web/src/components/AddProjectDialog.tsx | 74 +++++-- apps/web/src/components/Sidebar.tsx | 17 +- .../src/components/chat/DockExplorerPane.tsx | 13 +- .../components/chat/DockFilePane.browser.tsx | 148 ++++++++++++-- apps/web/src/components/chat/DockFilePane.tsx | 12 +- .../chat/useDockWorkspaceExplorer.ts | 83 ++++++-- ...oviderSelectionAfterConnection.browser.tsx | 46 +++++ .../useProviderSelectionAfterConnection.ts | 9 +- apps/web/src/lib/threadHandoff.test.ts | 41 +++- apps/web/src/lib/threadHandoff.ts | 32 ++- .../src/routes/-chatThreadRoute.logic.test.ts | 17 ++ apps/web/src/routes/-chatThreadRoute.logic.ts | 7 + apps/web/src/routes/_chat.$threadId.tsx | 23 ++- packages/shared/package.json | 4 + packages/shared/src/messageOrder.test.ts | 25 +++ packages/shared/src/messageOrder.ts | 21 ++ 22 files changed, 998 insertions(+), 110 deletions(-) create mode 100644 packages/shared/src/messageOrder.test.ts create mode 100644 packages/shared/src/messageOrder.ts diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 4ade972b7..24889808a 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -376,6 +376,198 @@ describe("OrchestrationEngine", () => { } }); + it("validates message-boundary forks against transcripts beyond the UI message cap", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const projectId = asProjectId("project-long-fork"); + const sourceThreadId = ThreadId.makeUnsafe("thread-long-fork-source"); + const baseTimestamp = Date.parse("2026-07-22T10:00:00.000Z"); + const sourceMessages = Array.from({ length: 2_002 }, (_, index) => { + const sequence = String(index).padStart(4, "0"); + const createdAt = new Date(baseTimestamp + index).toISOString(); + return { + messageId: asMessageId(`long-source-${sequence}`), + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: `Long transcript message ${sequence}`, + createdAt, + updatedAt: createdAt, + }; + }); + + try { + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-long-fork-create"), + projectId, + title: "Long fork project", + workspaceRoot: "/tmp/project-long-fork", + defaultModelSelection: { provider: "codex", model: "gpt-5-codex" }, + createdAt: sourceMessages[0]!.createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-long-fork-source-create"), + threadId: sourceThreadId, + projectId, + title: "Long source", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: sourceMessages[0]!.createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-thread-long-fork-source-import"), + threadId: sourceThreadId, + messages: sourceMessages, + createdAt: sourceMessages.at(-1)!.createdAt, + }), + ); + + await expect( + system.run( + engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-thread-long-fork-create"), + threadId: ThreadId.makeUnsafe("thread-long-fork-destination"), + sourceThreadId, + sourceMessageId: sourceMessages.at(-1)!.messageId, + projectId, + title: "Long fork", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: sourceMessages.map((message, index) => ({ + ...message, + messageId: asMessageId(`long-fork-${String(index).padStart(4, "0")}`), + })), + createdAt: new Date(baseTimestamp + sourceMessages.length).toISOString(), + }), + ), + ).resolves.toEqual(expect.objectContaining({ sequence: expect.any(Number) })); + + const destination = (await system.run(engine.getReadModel())).threads.find( + (thread) => thread.id === ThreadId.makeUnsafe("thread-long-fork-destination"), + ); + expect(destination?.messages).toHaveLength(2_000); + expect(destination?.messages.at(-1)?.text).toBe("Long transcript message 2001"); + } finally { + await system.dispose(); + } + }); + + it("validates hot same-timestamp fork messages in persistent projection order", async () => { + const createdAt = "2026-07-22T10:00:00.000Z"; + const system = await createOrchestrationSystem(); + const { engine } = system; + const projectId = asProjectId("project-same-time-fork"); + const sourceThreadId = ThreadId.makeUnsafe("thread-same-time-fork-source"); + + try { + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-same-time-fork-create"), + projectId, + title: "Same-time fork project", + workspaceRoot: "/tmp/project-same-time-fork", + defaultModelSelection: { provider: "codex", model: "gpt-5-codex" }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-same-time-fork-source-create"), + threadId: sourceThreadId, + projectId, + title: "Same-time source", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + // Deliberately append in the opposite order from the projection's + // created_at/message_id ordering to exercise the hot overlay. + await system.run( + engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-thread-same-time-fork-source-import"), + threadId: sourceThreadId, + messages: [ + { + messageId: asMessageId("same-time-z-assistant"), + role: "assistant", + text: "Same-time answer", + createdAt, + updatedAt: createdAt, + }, + { + messageId: asMessageId("same-time-a-user"), + role: "user", + text: "Same-time question", + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ); + + await expect( + system.run( + engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-thread-same-time-fork-create"), + threadId: ThreadId.makeUnsafe("thread-same-time-fork-destination"), + sourceThreadId, + sourceMessageId: asMessageId("same-time-z-assistant"), + projectId, + title: "Same-time fork", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("same-time-fork-a-user"), + role: "user", + text: "Same-time question", + createdAt, + updatedAt: createdAt, + }, + { + messageId: asMessageId("same-time-fork-z-assistant"), + role: "assistant", + text: "Same-time answer", + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ), + ).resolves.toEqual(expect.objectContaining({ sequence: expect.any(Number) })); + } finally { + await system.dispose(); + } + }); + it("rejects a fork import that reuses a source message id without moving the source row", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 23108d00f..785716b6c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -5,6 +5,7 @@ import type { ThreadId, } from "@synara/contracts"; import { OrchestrationCommand, ORCHESTRATION_WS_METHODS } from "@synara/contracts"; +import { compareProjectionMessageOrderValues } from "@synara/shared/messageOrder"; import { Cause, Deferred, @@ -255,17 +256,25 @@ const makeOrchestrationEngine = Effect.gen(function* () { const overlayThread = ( model: OrchestrationReadModel, thread: OrchestrationReadModel["threads"][number], + options: { readonly persistentMessageOrder?: boolean } = {}, ): OrchestrationReadModel => { const existingThread = model.threads.find((entry) => entry.id === thread.id); const hotMessageIds = new Set((existingThread?.messages ?? []).map((message) => message.id)); // The command model starts with no message bodies after a restart, then // accumulates new events in their authoritative order. Keep persisted-only - // history first and let the hot model replace matching rows. This also - // avoids reordering same-timestamp messages by the projection's id tie-break. - const mergedMessages = [ + // history first and let the hot model replace matching rows. + const overlaidMessages = [ ...thread.messages.filter((message) => !hotMessageIds.has(message.id)), ...(existingThread?.messages ?? []), ]; + // Message-boundary fork commands are constructed from the browser projection, + // whose stable tie-break is message id. Normalize only that validation surface; + // other commands still require authoritative hot event order. + const mergedMessages = options.persistentMessageOrder + ? overlaidMessages.toSorted((left, right) => + compareProjectionMessageOrderValues(left.createdAt, left.id, right.createdAt, right.id), + ) + : overlaidMessages; const mergedThread = { ...thread, messages: mergedMessages, @@ -283,12 +292,19 @@ const makeOrchestrationEngine = Effect.gen(function* () { command: OrchestrationCommand, model: OrchestrationReadModel, threadId: ThreadId, + options: { + readonly fullMessageHistory?: boolean; + readonly persistentMessageOrder?: boolean; + } = {}, ): Effect.Effect => - projectionSnapshotQuery.getThreadDetailById(threadId).pipe( + (options.fullMessageHistory + ? projectionSnapshotQuery.getThreadDetailForExportById(threadId) + : projectionSnapshotQuery.getThreadDetailById(threadId) + ).pipe( Effect.map((threadOption) => Option.match(threadOption, { onNone: () => model, - onSome: (thread) => overlayThread(model, thread), + onSome: (thread) => overlayThread(model, thread, options), }), ), Effect.mapError( @@ -306,8 +322,15 @@ const makeOrchestrationEngine = Effect.gen(function* () { ): Effect.Effect => { switch (command.type) { case "thread.handoff.create": - case "thread.fork.create": return loadThreadDetailForDecider(command, commandReadModel, command.sourceThreadId); + case "thread.fork.create": + // Message-boundary forks promise an exact authoritative prefix. The normal + // thread detail query is deliberately capped for UI reads, so validation + // must use the existing uncapped export projection instead. + return loadThreadDetailForDecider(command, commandReadModel, command.sourceThreadId, { + fullMessageHistory: true, + persistentMessageOrder: true, + }); case "thread.turn.start": return command.sourceProposedPlan ? loadThreadDetailForDecider( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ac04f7b31..1a727c4e3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -723,6 +723,8 @@ describe("ProviderCommandReactor", () => { }, }); const now = new Date().toISOString(); + const boundaryCreatedAt = new Date(Date.parse(now) + 1).toISOString(); + const afterBoundaryCreatedAt = new Date(Date.parse(now) + 2).toISOString(); const importedAttachments = Array.from({ length: 9 }, (_, index) => ({ type: "file" as const, id: `attachment-before-boundary-${index + 1}`, @@ -757,8 +759,8 @@ describe("ProviderCommandReactor", () => { role: "assistant", text: "Answer at the branch boundary", attachments: importedAttachments.slice(8), - createdAt: now, - updatedAt: now, + createdAt: boundaryCreatedAt, + updatedAt: boundaryCreatedAt, }, { messageId: asMessageId("source-after-boundary-user"), @@ -773,8 +775,8 @@ describe("ProviderCommandReactor", () => { sizeBytes: 11, }, ], - createdAt: now, - updatedAt: now, + createdAt: afterBoundaryCreatedAt, + updatedAt: afterBoundaryCreatedAt, }, ], createdAt: now, @@ -813,8 +815,8 @@ describe("ProviderCommandReactor", () => { role: "assistant", text: "Answer at the branch boundary", attachments: importedAttachments.slice(8), - createdAt: now, - updatedAt: now, + createdAt: boundaryCreatedAt, + updatedAt: boundaryCreatedAt, }, ], createdAt: now, @@ -902,6 +904,7 @@ describe("ProviderCommandReactor", () => { const forkThreadId = ThreadId.makeUnsafe("thread-message-fork-restart"); const sourceBoundaryId = asMessageId("source-message-fork-restart-boundary"); const createdAt = new Date().toISOString(); + const boundaryCreatedAt = new Date(Date.parse(createdAt) + 1).toISOString(); const firstHarness = await createHarness({ baseDir, filePersistence: true }); await Effect.runPromise( @@ -930,8 +933,8 @@ describe("ProviderCommandReactor", () => { messageId: sourceBoundaryId, role: "assistant", text: "Persisted answer before restart", - createdAt, - updatedAt: createdAt, + createdAt: boundaryCreatedAt, + updatedAt: boundaryCreatedAt, }, ], createdAt, @@ -973,8 +976,8 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("fork-00000001-message-restart-assistant"), role: "assistant", text: "Persisted answer before restart", - createdAt, - updatedAt: createdAt, + createdAt: boundaryCreatedAt, + updatedAt: boundaryCreatedAt, }, ], createdAt, @@ -1023,6 +1026,116 @@ describe("ProviderCommandReactor", () => { ]); }); + it("includes an interrupted first fork prompt when a fresh provider session restarts", async () => { + const harness = await createHarness(); + const forkThreadId = ThreadId.makeUnsafe("thread-message-fork-interrupted-restart"); + const sourceBoundaryId = asMessageId("source-message-fork-interrupted-boundary"); + const createdAt = "2026-07-22T10:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-message-fork-interrupted-source-import"), + threadId: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + messageId: asMessageId("source-message-fork-interrupted-user"), + role: "user", + text: "Imported question before interruption", + createdAt, + updatedAt: createdAt, + }, + { + messageId: sourceBoundaryId, + role: "assistant", + text: "Imported answer before interruption", + createdAt: "2026-07-22T10:00:01.000Z", + updatedAt: "2026-07-22T10:00:01.000Z", + }, + ], + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-message-fork-interrupted-create"), + threadId: forkThreadId, + sourceThreadId: ThreadId.makeUnsafe("thread-1"), + sourceMessageId: sourceBoundaryId, + projectId: asProjectId("project-1"), + title: "Interrupted message fork", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("fork-interrupted-a-user"), + role: "user", + text: "Imported question before interruption", + createdAt, + updatedAt: createdAt, + }, + { + messageId: asMessageId("fork-interrupted-b-assistant"), + role: "assistant", + text: "Imported answer before interruption", + createdAt: "2026-07-22T10:00:01.000Z", + updatedAt: "2026-07-22T10:00:01.000Z", + }, + ], + createdAt: "2026-07-22T10:00:02.000Z", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-message-fork-interrupted-first-turn"), + threadId: forkThreadId, + message: { + messageId: asMessageId("message-fork-interrupted-first-user"), + role: "user", + text: "Interrupted native prompt", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: "2026-07-22T10:00:03.000Z", + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise(harness.stopSession({ threadId: forkThreadId })); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-message-fork-interrupted-second-turn"), + threadId: forkThreadId, + message: { + messageId: asMessageId("message-fork-interrupted-second-user"), + role: "user", + text: "Continue after the provider restart", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: "2026-07-22T10:00:04.000Z", + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + const restartedInput = harness.sendTurn.mock.calls[1]?.[0] as { input?: string } | undefined; + expect(restartedInput?.input).toContain(""); + expect(restartedInput?.input).toContain("Imported question before interruption"); + expect(restartedInput?.input).toContain("Imported answer before interruption"); + expect(restartedInput?.input).toContain("Interrupted native prompt"); + expect(restartedInput?.input).toContain("Continue after the provider restart"); + }); + it("bootstraps Droid sidechat context after a native provider fork", async () => { const threadId = ThreadId.makeUnsafe("thread-native-droid-sidechat"); const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2ec509bff..ff01255d3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -161,7 +161,7 @@ function isInitialMessageForkContextBootstrap( return false; } return !thread.messages.slice(0, currentMessageIndex).some((message) => { - return message.role === "assistant" && message.source === "native"; + return (message.role === "user" || message.role === "assistant") && message.source === "native"; }); } diff --git a/apps/server/src/orchestration/messageFork.ts b/apps/server/src/orchestration/messageFork.ts index 903fb064a..40c56b6b2 100644 --- a/apps/server/src/orchestration/messageFork.ts +++ b/apps/server/src/orchestration/messageFork.ts @@ -10,6 +10,7 @@ import type { ThreadHandoffImportedMessage, } from "@synara/contracts"; import { stripEmbeddedAssistantSelections } from "@synara/shared/assistantSelections"; +import { compareProjectionMessageOrderValues } from "@synara/shared/messageOrder"; import { isAssistantTurnTerminal } from "./assistantMessageLifecycle.ts"; @@ -194,8 +195,12 @@ export function validateMessageForkImport(input: { return true; } return ( - previous.createdAt < message.createdAt || - (previous.createdAt === message.createdAt && previous.messageId < message.messageId) + compareProjectionMessageOrderValues( + previous.createdAt, + previous.messageId, + message.createdAt, + message.messageId, + ) < 0 ); }); if (!importOrderIsPersistent) { diff --git a/apps/web/src/components/AddProjectDialog.browser.tsx b/apps/web/src/components/AddProjectDialog.browser.tsx index 47d3715de..ead3c373d 100644 --- a/apps/web/src/components/AddProjectDialog.browser.tsx +++ b/apps/web/src/components/AddProjectDialog.browser.tsx @@ -49,7 +49,7 @@ function installNativeApi(overrides: { }; } -function renderDialog(onAddProjectPath = vi.fn().mockResolvedValue(undefined)) { +function renderDialog(onAddProjectPath = vi.fn().mockResolvedValue(true)) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( @@ -123,7 +123,7 @@ describe("AddProjectDialog", () => { it("clones an available GitHub repository and passes the result to Scient initialization", async () => { const cloneSource = vi.fn().mockResolvedValue({ path: "/Users/tester/scient" }); - const onAddProjectPath = vi.fn().mockResolvedValue(undefined); + const onAddProjectPath = vi.fn().mockResolvedValue(true); const restore = installNativeApi({ statuses: vi.fn().mockResolvedValue({ sources: [ @@ -151,4 +151,165 @@ describe("AddProjectDialog", () => { expect(onAddProjectPath).toHaveBeenCalledWith("/Users/tester/scient"); restore(); }); + + it("ignores a clone completion after the dialog is dismissed and reopened", async () => { + let resolveClone!: (value: { path: string }) => void; + const cloneSource = vi.fn( + () => + new Promise<{ path: string }>((resolve) => { + resolveClone = resolve; + }), + ); + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const onOpenChange = vi.fn(); + const restore = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ + sources: [{ provider: "github", status: "available", message: "GitHub CLI is ready." }], + }), + cloneSource, + browse: vi.fn().mockResolvedValue({ parentPath: "/Users/tester", entries: [] }), + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const dialog = (open: boolean) => ( + + + + ); + const screen = await render(dialog(true)); + + try { + await page.getByText("GitHub repository", { exact: true }).click(); + await page.getByPlaceholder("owner/repository").fill("ScientFactory/scient"); + await page.getByRole("button", { name: /Continue/ }).click(); + await page.getByRole("button", { name: /Clone/ }).click(); + expect(cloneSource).toHaveBeenCalledTimes(1); + + await screen.rerender(dialog(false)); + await screen.rerender(dialog(true)); + await expect.element(page.getByText("Sources", { exact: true })).toBeVisible(); + + resolveClone({ path: "/Users/tester/scient" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onAddProjectPath).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + await expect.element(page.getByText("Sources", { exact: true })).toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restore(); + } + }); + + it("ignores a clone completion after navigating back from its destination", async () => { + let resolveClone!: (value: { path: string }) => void; + const cloneSource = vi.fn( + () => + new Promise<{ path: string }>((resolve) => { + resolveClone = resolve; + }), + ); + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const onOpenChange = vi.fn(); + const restore = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ + sources: [{ provider: "github", status: "available", message: "GitHub CLI is ready." }], + }), + cloneSource, + browse: vi.fn().mockResolvedValue({ parentPath: "/Users/tester", entries: [] }), + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const screen = await render( + + + , + ); + + try { + await page.getByText("GitHub repository", { exact: true }).click(); + await page.getByPlaceholder("owner/repository").fill("ScientFactory/scient"); + await page.getByRole("button", { name: /Continue/ }).click(); + await page.getByRole("button", { name: /Clone/ }).click(); + expect(cloneSource).toHaveBeenCalledTimes(1); + + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect.element(page.getByPlaceholder("owner/repository")).toBeVisible(); + + resolveClone({ path: "/Users/tester/scient" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onAddProjectPath).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + await expect.element(page.getByPlaceholder("owner/repository")).toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restore(); + } + }); + + it("ignores project initialization completion after the dialog is dismissed and reopened", async () => { + let resolveAdd!: (shouldClose: boolean) => void; + const onAddProjectPath = vi.fn( + () => + new Promise((resolve) => { + resolveAdd = resolve; + }), + ); + const onOpenChange = vi.fn(); + const restore = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ + sources: [{ provider: "github", status: "available", message: "GitHub CLI is ready." }], + }), + cloneSource: vi.fn().mockResolvedValue({ path: "/Users/tester/scient" }), + browse: vi.fn().mockResolvedValue({ parentPath: "/Users/tester", entries: [] }), + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const dialog = (open: boolean) => ( + + + + ); + const screen = await render(dialog(true)); + + try { + await page.getByText("GitHub repository", { exact: true }).click(); + await page.getByPlaceholder("owner/repository").fill("ScientFactory/scient"); + await page.getByRole("button", { name: /Continue/ }).click(); + await page.getByRole("button", { name: /Clone/ }).click(); + await vi.waitFor(() => expect(onAddProjectPath).toHaveBeenCalledTimes(1)); + + await screen.rerender(dialog(false)); + await screen.rerender(dialog(true)); + await expect.element(page.getByText("Sources", { exact: true })).toBeVisible(); + + resolveAdd(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onOpenChange).not.toHaveBeenCalled(); + await expect.element(page.getByText("Sources", { exact: true })).toBeVisible(); + } finally { + await screen.unmount(); + queryClient.clear(); + restore(); + } + }); }); diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx index 769b20e79..b42c4b65a 100644 --- a/apps/web/src/components/AddProjectDialog.tsx +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -4,7 +4,7 @@ import type { RepositorySourceStatus, } from "@synara/contracts"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { SiGithub, SiGitlab } from "react-icons/si"; import { LuArrowLeft, LuCornerLeftUp, LuFolderPlus, LuLink } from "react-icons/lu"; @@ -53,7 +53,7 @@ type DialogStep = "sources" | "local" | "repository" | "destination"; interface AddProjectDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onAddProjectPath: (path: string, options?: { createIfMissing?: boolean }) => Promise; + onAddProjectPath: (path: string, options?: { createIfMissing?: boolean }) => Promise; homeDir: string | null; defaultCloneDirectory: string | null; } @@ -378,6 +378,9 @@ export function AddProjectDialog(props: AddProjectDialogProps) { const [error, setError] = useState(null); const [isWorking, setIsWorking] = useState(false); const [highlightedSource, setHighlightedSource] = useState(null); + const openRef = useRef(props.open); + const operationGenerationRef = useRef(0); + openRef.current = props.open; const statusesQuery = useQuery({ queryKey: ["repository-source-statuses"], queryFn: async () => { @@ -391,6 +394,7 @@ export function AddProjectDialog(props: AddProjectDialogProps) { useEffect(() => { if (!props.open) { + operationGenerationRef.current += 1; setStep("sources"); setSource(null); setQuery(""); @@ -401,7 +405,41 @@ export function AddProjectDialog(props: AddProjectDialogProps) { } }, [props.open]); + const handleOpenChange = (open: boolean) => { + if (!open) { + // Native clone requests are not cancellable. Invalidate their completion + // synchronously so a dismissed request cannot mutate a later dialog session. + operationGenerationRef.current += 1; + } + props.onOpenChange(open); + }; + + const beginOperation = (): number => { + const generation = operationGenerationRef.current + 1; + operationGenerationRef.current = generation; + setIsWorking(true); + return generation; + }; + + const isCurrentOperation = (generation: number): boolean => + openRef.current && operationGenerationRef.current === generation; + + const finishCurrentOperation = (generation: number): void => { + if (isCurrentOperation(generation)) { + setIsWorking(false); + } + }; + + const cancelCurrentOperation = (): void => { + // Clone and project-initialization requests cannot be cancelled natively. + // Invalidate their completion before navigating so an old request cannot + // advance or close the dialog from a different step. + operationGenerationRef.current += 1; + setIsWorking(false); + }; + const returnToSources = () => { + cancelCurrentOperation(); setStep("sources"); setSource(null); setQuery(""); @@ -468,7 +506,7 @@ export function AddProjectDialog(props: AddProjectDialogProps) { }; return ( - + {step === "local" ? ( { - setIsWorking(true); + const operationGeneration = beginOperation(); try { - await props.onAddProjectPath(path, options); - props.onOpenChange(false); + const shouldClose = await props.onAddProjectPath(path, options); + if (!isCurrentOperation(operationGeneration)) return; + if (shouldClose) { + setIsWorking(false); + handleOpenChange(false); + } } finally { - setIsWorking(false); + finishCurrentOperation(operationGeneration); } }} /> @@ -497,13 +539,14 @@ export function AddProjectDialog(props: AddProjectDialogProps) { isBusy={isWorking} cloneDirectoryName={cloneName} onBack={() => { + cancelCurrentOperation(); setQuery(repositoryInput); setStep("repository"); }} onSubmit={async (destinationPath) => { const api = readNativeApi(); if (!api) throw new Error("The app server is unavailable."); - setIsWorking(true); + const operationGeneration = beginOperation(); try { const result = await api.projects.cloneSource( buildCloneProjectSourceInput({ @@ -512,10 +555,15 @@ export function AddProjectDialog(props: AddProjectDialogProps) { destinationPath, }), ); - await props.onAddProjectPath(result.path); - props.onOpenChange(false); + if (!isCurrentOperation(operationGeneration)) return; + const shouldClose = await props.onAddProjectPath(result.path); + if (!isCurrentOperation(operationGeneration)) return; + if (shouldClose) { + setIsWorking(false); + handleOpenChange(false); + } } finally { - setIsWorking(false); + finishCurrentOperation(operationGeneration); } }} /> @@ -531,9 +579,7 @@ export function AddProjectDialog(props: AddProjectDialogProps) {
- step === "sources" ? props.onOpenChange(false) : returnToSources() - } + onClick={() => (step === "sources" ? handleOpenChange(false) : returnToSources())} /> { - setAddProjectDialogOpen(false); - }; try { const preparation = await prepareScientProjectForOpening({ @@ -2724,7 +2721,7 @@ export default function Sidebar() { onCompletion: notifyProjectInitializationCompletion, }); if (preparation === "cancel") { - return; + return false; } const existing = findWorkspaceRootMatch(projects, cwd, (project) => project.cwd); @@ -2736,8 +2733,7 @@ export default function Sidebar() { recoverExistingProjectByWorkspaceRootFromServer(api, workspaceRoot), }); if (existingRecovery === "recovered") { - finishAddingProject(); - return; + return true; } if (existing) { // Local project state can briefly outlive a server-side project.deleted event. @@ -2768,16 +2764,14 @@ export default function Sidebar() { creationResult.snapshot, ); if (recovered) { - finishAddingProject(); - return; + return true; } } if (!creationResult.created) { const recovered = await recoverExistingProjectFromServer(api, creationResult.projectId); if (recovered) { - finishAddingProject(); - return; + return true; } throw new Error(PROJECT_CREATE_EXISTING_SYNC_ERROR); } @@ -2789,8 +2783,7 @@ export default function Sidebar() { void handleNewThread(creationResult.projectId, { envMode: appSettings.defaultThreadEnvMode, }).catch(() => undefined); - finishAddingProject(); - return; + return true; } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; diff --git a/apps/web/src/components/chat/DockExplorerPane.tsx b/apps/web/src/components/chat/DockExplorerPane.tsx index ed82a2186..0e7cef50a 100644 --- a/apps/web/src/components/chat/DockExplorerPane.tsx +++ b/apps/web/src/components/chat/DockExplorerPane.tsx @@ -8,28 +8,27 @@ import { memo } from "react"; import type { ChatFileReference } from "~/lib/chatReferences"; import { WorkspaceExplorerSidebar } from "./workspaceExplorer"; -import { useDockWorkspaceExplorer } from "./useDockWorkspaceExplorer"; +import type { DockWorkspaceExplorerController } from "./useDockWorkspaceExplorer"; const DOCK_EXPLORER_SIDEBAR_CLASS = "flex h-full min-h-0 w-full min-w-0 flex-col bg-[var(--color-background-surface)]"; export const DockExplorerPane = memo(function DockExplorerPane(props: { workspaceRoot: string | null; + explorer: DockWorkspaceExplorerController; onOpenFile: (path: string) => void; onReferenceInChat?: ((reference: ChatFileReference) => void) | undefined; }) { - const explorer = useDockWorkspaceExplorer(); - return ( ); diff --git a/apps/web/src/components/chat/DockFilePane.browser.tsx b/apps/web/src/components/chat/DockFilePane.browser.tsx index 7d1aea8f3..f1a76afc7 100644 --- a/apps/web/src/components/chat/DockFilePane.browser.tsx +++ b/apps/web/src/components/chat/DockFilePane.browser.tsx @@ -4,40 +4,102 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { page } from "vitest/browser"; import { describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; +import { useState } from "react"; import { projectQueryKeys } from "~/lib/projectReactQuery"; +import { DockExplorerPane } from "./DockExplorerPane"; import { DockFilePane } from "./DockFilePane"; +import { useDockWorkspaceExplorer } from "./useDockWorkspaceExplorer"; function createQueryClient() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, }); - const workspaceRoot = "/project"; - queryClient.setQueryData(projectQueryKeys.readFile(workspaceRoot, "README.md"), { - relativePath: "README.md", - contents: "# Scient\n\nA local-first scientific workspace.", - truncated: false, - }); - queryClient.setQueryData(projectQueryKeys.listDirectories(workspaceRoot, null, true), { - entries: [ - { path: "README.md", name: "README.md", kind: "file" }, - { path: "notes.md", name: "notes.md", kind: "file" }, - ], - }); + for (const workspaceRoot of ["/project", "/other-project"]) { + queryClient.setQueryData(projectQueryKeys.readFile(workspaceRoot, "README.md"), { + relativePath: "README.md", + contents: "# Scient\n\nA local-first scientific workspace.", + truncated: false, + }); + queryClient.setQueryData(projectQueryKeys.readFile(workspaceRoot, "notes.md"), { + relativePath: "notes.md", + contents: "# Notes\n\nPersistent explorer state.", + truncated: false, + }); + queryClient.setQueryData(projectQueryKeys.listDirectories(workspaceRoot, null, true), { + entries: [ + { path: "src", name: "src", kind: "directory" }, + { path: "README.md", name: "README.md", kind: "file" }, + { path: "notes.md", name: "notes.md", kind: "file" }, + ], + }); + queryClient.setQueryData(projectQueryKeys.listDirectories(workspaceRoot, "src", true), { + entries: [{ path: "src/index.ts", name: "index.ts", kind: "file" }], + }); + queryClient.setQueryData(projectQueryKeys.searchEntries(workspaceRoot, "notes", 80, "file"), { + entries: [{ path: "notes.md", name: "notes.md", kind: "file" }], + truncated: false, + }); + } return queryClient; } +function ExplorerTransitionHarness() { + const [workspaceRoot, setWorkspaceRoot] = useState("/project"); + const explorer = useDockWorkspaceExplorer(workspaceRoot); + const [filePath, setFilePath] = useState(null); + + return ( + <> + + + {filePath ? ( + + ) : ( + + )} + + ); +} + describe("DockFilePane", () => { it("keeps the file and controlled explorer visible together", async () => { const queryClient = createQueryClient(); const onOpenFile = vi.fn(); + const explorer = { + expandedDirectories: new Set(), + searchQuery: "", + setSearchQuery: vi.fn(), + toggleDirectory: vi.fn(), + }; const pane = (explorerOpen: boolean) => ( @@ -65,4 +127,66 @@ describe("DockFilePane", () => { queryClient.clear(); } }); + + it("preserves explorer search while promoting a file and returning to the explorer", async () => { + const queryClient = createQueryClient(); + const screen = await render( + + + , + ); + + try { + const search = page.getByRole("textbox", { name: "Search files" }); + await search.fill("notes"); + await page.getByRole("button", { name: /^notes\.md/ }).click(); + + await expect.element(page.getByRole("heading", { name: "Notes" })).toBeVisible(); + await expect + .element(page.getByRole("textbox", { name: "Search files" })) + .toHaveValue("notes"); + + await page.getByRole("button", { name: "Show standalone explorer" }).click(); + await expect + .element(page.getByRole("textbox", { name: "Search files" })) + .toHaveValue("notes"); + } finally { + await screen.unmount(); + queryClient.clear(); + } + }); + + it("resets explorer state when the workspace scope changes", async () => { + const queryClient = createQueryClient(); + const screen = await render( + + + , + ); + + try { + const sourceDirectory = page.getByRole("button", { name: /^src/ }); + await sourceDirectory.click(); + await expect.element(sourceDirectory).toHaveAttribute("aria-expanded", "true"); + + const search = page.getByRole("textbox", { name: "Search files" }); + await search.fill("notes"); + await expect.element(search).toHaveValue("notes"); + + await page.getByRole("button", { name: "Switch workspace" }).click(); + await expect.element(page.getByRole("textbox", { name: "Search files" })).toHaveValue(""); + await expect + .element(page.getByRole("button", { name: /^src/ })) + .toHaveAttribute("aria-expanded", "false"); + + await page.getByRole("button", { name: "Switch workspace" }).click(); + await expect.element(page.getByRole("textbox", { name: "Search files" })).toHaveValue(""); + await expect + .element(page.getByRole("button", { name: /^src/ })) + .toHaveAttribute("aria-expanded", "false"); + } finally { + await screen.unmount(); + queryClient.clear(); + } + }); }); diff --git a/apps/web/src/components/chat/DockFilePane.tsx b/apps/web/src/components/chat/DockFilePane.tsx index f5ea5ce67..1677ce6d9 100644 --- a/apps/web/src/components/chat/DockFilePane.tsx +++ b/apps/web/src/components/chat/DockFilePane.tsx @@ -14,7 +14,7 @@ import { cn } from "~/lib/utils"; import { WorkspaceFilePreview } from "../WorkspaceFilePreview"; import { PanelStateMessage } from "./PanelStateMessage"; import { WorkspaceExplorerSidebar } from "./workspaceExplorer"; -import { useDockWorkspaceExplorer } from "./useDockWorkspaceExplorer"; +import type { DockWorkspaceExplorerController } from "./useDockWorkspaceExplorer"; const DOCK_FILE_EXPLORER_WIDTH_CLASS = "w-[min(22rem,46%)]"; const DOCK_FILE_EXPLORER_CONTENT_CLASS = @@ -24,12 +24,12 @@ export const DockFilePane = memo(function DockFilePane(props: { workspaceRoot: string | null; filePath: string | null; explorerOpen: boolean; + explorer: DockWorkspaceExplorerController; onOpenFile: (path: string) => void; onReferenceInChat?: ((reference: ChatFileReference) => void) | undefined; onAskWhyInChat?: ((reference: ChatFileReference) => void) | undefined; onCommentInChat?: ((comment: FileCommentSelection) => void) | undefined; }) { - const explorer = useDockWorkspaceExplorer(); const hasFile = props.filePath !== null; const selectedWorkspaceFilePath = props.filePath !== null && isWorkspaceRelativePathSafe(props.filePath) ? props.filePath : null; @@ -66,12 +66,12 @@ export const DockFilePane = memo(function DockFilePane(props: {
diff --git a/apps/web/src/components/chat/useDockWorkspaceExplorer.ts b/apps/web/src/components/chat/useDockWorkspaceExplorer.ts index 20cb7de08..9bff8e774 100644 --- a/apps/web/src/components/chat/useDockWorkspaceExplorer.ts +++ b/apps/web/src/components/chat/useDockWorkspaceExplorer.ts @@ -2,29 +2,74 @@ // Purpose: Shared directory-expansion and search state for dock-hosted explorers. // Layer: Chat right-dock UI state -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; -export function useDockWorkspaceExplorer() { - const [expandedDirectories, setExpandedDirectories] = useState>( - () => new Set(), +export interface DockWorkspaceExplorerController { + readonly expandedDirectories: ReadonlySet; + readonly searchQuery: string; + readonly setSearchQuery: (query: string) => void; + readonly toggleDirectory: (path: string) => void; +} + +interface DockWorkspaceExplorerState { + readonly scopeKey: string; + readonly expandedDirectories: ReadonlySet; + readonly searchQuery: string; +} + +function emptyExplorerState(scopeKey: string): DockWorkspaceExplorerState { + return { + scopeKey, + expandedDirectories: new Set(), + searchQuery: "", + }; +} + +export function useDockWorkspaceExplorer(scopeKey: string): DockWorkspaceExplorerController { + const [state, setState] = useState(() => + emptyExplorerState(scopeKey), + ); + // Derive an empty state synchronously so the first render for a new thread or + // workspace can never display the previous scope's query or expanded paths. + const scopedState = state.scopeKey === scopeKey ? state : emptyExplorerState(scopeKey); + + useEffect(() => { + setState((current) => (current.scopeKey === scopeKey ? current : emptyExplorerState(scopeKey))); + }, [scopeKey]); + + const setSearchQuery = useCallback( + (query: string) => { + setState((current) => { + const scopedCurrent = + current.scopeKey === scopeKey ? current : emptyExplorerState(scopeKey); + return scopedCurrent.searchQuery === query + ? scopedCurrent + : { ...scopedCurrent, searchQuery: query }; + }); + }, + [scopeKey], + ); + + const toggleDirectory = useCallback( + (path: string) => { + setState((current) => { + const scopedCurrent = + current.scopeKey === scopeKey ? current : emptyExplorerState(scopeKey); + const next = new Set(scopedCurrent.expandedDirectories); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return { ...scopedCurrent, expandedDirectories: next }; + }); + }, + [scopeKey], ); - const [searchQuery, setSearchQuery] = useState(""); - - const toggleDirectory = useCallback((path: string) => { - setExpandedDirectories((current) => { - const next = new Set(current); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - }, []); return { - expandedDirectories, - searchQuery, + expandedDirectories: scopedState.expandedDirectories, + searchQuery: scopedState.searchQuery, setSearchQuery, toggleDirectory, }; diff --git a/apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx b/apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx index dc54f4e05..1286e5dfb 100644 --- a/apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx +++ b/apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx @@ -106,6 +106,28 @@ function SelectionHarness() { ]); }; + const setClaudeInstallation = (installationStatus: "installing" | "installed") => { + setStatuses([ + CODEX_READY, + { + ...CLAUDE_UNAVAILABLE, + available: installationStatus === "installed", + checkedAt: "2026-07-23T10:00:02.000Z", + installationState: { + operationId: "install-claude-1", + operation: "install", + status: installationStatus, + startedAt: "2099-07-23T10:00:01.000Z", + finishedAt: installationStatus === "installed" ? "2099-07-23T10:00:02.000Z" : null, + message: + installationStatus === "installed" + ? "Claude is installed and verified." + : "Installing Claude.", + }, + }, + ]); + }; + return ( <> {`${provider}:${model}`} @@ -127,6 +149,12 @@ function SelectionHarness() { + + @@ -189,6 +217,24 @@ describe("provider selection after connection browser journey", () => { } }); + it("clears the selection intent when setup is dismissed after installation", async () => { + const screen = await render(); + try { + await page.getByRole("button", { name: "GPT-5.5" }).click(); + await page.getByRole("menuitem", { name: /Claude.*Set up/u }).click(); + await page.getByRole("button", { name: "Start installation" }).click(); + await page.getByRole("button", { name: "Complete installation" }).click(); + useProviderConnectionDialogStore.getState().setOpen(false); + await page.getByRole("button", { name: "Complete connection" }).click(); + + await expect + .element(page.getByLabelText("Selected provider and model")) + .toHaveTextContent("codex:gpt-5.5"); + } finally { + await screen.unmount(); + } + }); + it("does not override a composer that becomes provider-locked during connection", async () => { const screen = await render(); try { diff --git a/apps/web/src/hooks/useProviderSelectionAfterConnection.ts b/apps/web/src/hooks/useProviderSelectionAfterConnection.ts index 7ba3a20e0..2c8fa87ca 100644 --- a/apps/web/src/hooks/useProviderSelectionAfterConnection.ts +++ b/apps/web/src/hooks/useProviderSelectionAfterConnection.ts @@ -306,14 +306,13 @@ export function useApplyProviderSelectionAfterConnection(input: { ACTIVE_CONNECTION_STATUSES.has(status.connectionState.status)) || (status?.installationState && ACTIVE_INSTALLATION_STATUSES.has(status.installationState.status)); - const operationWasObserved = - outcome.intent.observedConnectionOperationId !== null || - outcome.intent.observedInstallationOperationId !== null; + // Closing the matching picker after every operation has stopped is an + // explicit cancellation, even when an earlier operation completed without + // making the provider usable. Do not let that stale intent apply later. if ( seenPickerDialogTokenRef.current === intent.token && !pickerDialogMatchesIntent && - !operationStillRunning && - !operationWasObserved + !operationStillRunning ) { controller.clear(intent.token); return; diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index 2ac5e7b15..5265483a4 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -255,12 +255,12 @@ describe("threadHandoff", () => { }); it("assigns equal-timestamp imports ids whose persisted sort preserves source order", () => { - const boundaryId = MessageId.makeUnsafe("message-equal-time-assistant"); + const boundaryId = MessageId.makeUnsafe("message-equal-time-z-assistant"); const imported = buildThreadForkImportedMessagesThrough( { messages: [ { - id: MessageId.makeUnsafe("message-equal-time-user"), + id: MessageId.makeUnsafe("message-equal-time-a-user"), role: "user", text: "First", createdAt: "2026-07-22T08:00:00.000Z", @@ -283,6 +283,43 @@ describe("threadHandoff", () => { expect(imported[0]!.messageId < imported[1]!.messageId).toBe(true); }); + it("normalizes hot equal-timestamp source messages to projection order before forking", () => { + const createdAt = "2026-07-22T08:00:00.000Z"; + const assistantId = MessageId.makeUnsafe("same-time-z-assistant"); + const userId = MessageId.makeUnsafe("same-time-a-user"); + const thread = { + // Live events can arrive in provider order before a projection refresh. + messages: [ + { + id: assistantId, + role: "assistant" as const, + text: "Same-time answer", + createdAt, + streaming: false, + }, + { + id: userId, + role: "user" as const, + text: "Same-time question", + createdAt, + streaming: false, + }, + ], + }; + + expect([...resolveThreadForkableMessageIds(thread)]).toEqual([userId, assistantId]); + expect( + buildThreadForkImportedMessagesThrough( + thread, + assistantId, + ThreadId.makeUnsafe("thread-fork-hot-equal-time"), + ).map(({ role, text }) => ({ role, text })), + ).toEqual([ + { role: "user", text: "Same-time question" }, + { role: "assistant", text: "Same-time answer" }, + ]); + }); + it("treats an active-turn assistant as unsafe even before its streaming flag arrives", () => { const assistantId = MessageId.makeUnsafe("message-active-nonstreaming-assistant"); const turnId = TurnId.makeUnsafe("turn-active-nonstreaming-assistant"); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 91b9433cc..b6ab38fc8 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -13,6 +13,7 @@ import { type ProviderKind, type ThreadHandoffImportedMessage, } from "@synara/contracts"; +import { compareProjectionMessageOrderValues } from "@synara/shared/messageOrder"; import { getDefaultModel } from "@synara/shared/model"; import { isLatestTurnSettled } from "../session-logic"; import { type Thread } from "../types"; @@ -47,6 +48,15 @@ function isImportableThreadMessage( type ForkSourceThread = Pick & Partial>; +function inProjectionMessageOrder(thread: ForkSourceThread): ForkSourceThread { + return { + ...thread, + messages: thread.messages.toSorted((left, right) => + compareProjectionMessageOrderValues(left.createdAt, left.id, right.createdAt, right.id), + ), + }; +} + function resolveRunningTurnBoundaryIndex(thread: ForkSourceThread): number | null { if (!thread.latestTurn || isLatestTurnSettled(thread.latestTurn, thread.session ?? null)) { return null; @@ -182,9 +192,12 @@ export function buildThreadForkImportedMessagesThrough( sourceMessageId: MessageId, destinationThreadId: ThreadId, ): ReadonlyArray { - const sourceMessageIndex = thread.messages.findIndex((message) => message.id === sourceMessageId); - const sourceMessage = thread.messages[sourceMessageIndex]; - const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread); + const orderedThread = inProjectionMessageOrder(thread); + const sourceMessageIndex = orderedThread.messages.findIndex( + (message) => message.id === sourceMessageId, + ); + const sourceMessage = orderedThread.messages[sourceMessageIndex]; + const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(orderedThread); if ( sourceMessageIndex < 0 || !sourceMessage || @@ -193,12 +206,12 @@ export function buildThreadForkImportedMessagesThrough( return []; } - const conversationPrefix = thread.messages + const conversationPrefix = orderedThread.messages .slice(0, sourceMessageIndex + 1) .filter((message) => message.role === "user" || message.role === "assistant"); if ( conversationPrefix.length === 0 || - conversationPrefix.some((message) => !isForkImportableThreadMessage(thread, message)) || + conversationPrefix.some((message) => !isForkImportableThreadMessage(orderedThread, message)) || conversationPrefix.some((message, index, messages) => { const previous = messages[index - 1]; return previous !== undefined && previous.createdAt > message.createdAt; @@ -209,7 +222,7 @@ export function buildThreadForkImportedMessagesThrough( return buildImportedMessages( conversationPrefix, - (message) => isForkImportableThreadMessage(thread, message), + (message) => isForkImportableThreadMessage(orderedThread, message), (index) => MessageId.makeUnsafe( `fork:${destinationThreadId}:${String(index).padStart(8, "0")}:${randomUUID()}`, @@ -223,18 +236,19 @@ export function buildThreadForkImportedMessagesThrough( * or assistant boundary stay hidden until the lifecycle settles. */ export function resolveThreadForkableMessageIds(thread: ForkSourceThread): ReadonlySet { + const orderedThread = inProjectionMessageOrder(thread); const forkableMessageIds = new Set(); let prefixIsImportable = true; - const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread); + const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(orderedThread); - for (const [messageIndex, message] of thread.messages.entries()) { + for (const [messageIndex, message] of orderedThread.messages.entries()) { if (message.role !== "user" && message.role !== "assistant") { continue; } prefixIsImportable = prefixIsImportable && (runningTurnBoundaryIndex === null || messageIndex < runningTurnBoundaryIndex) && - isForkImportableThreadMessage(thread, message); + isForkImportableThreadMessage(orderedThread, message); if (prefixIsImportable) { forkableMessageIds.add(message.id); } diff --git a/apps/web/src/routes/-chatThreadRoute.logic.test.ts b/apps/web/src/routes/-chatThreadRoute.logic.test.ts index b9303e37c..d33bfd4ec 100644 --- a/apps/web/src/routes/-chatThreadRoute.logic.test.ts +++ b/apps/web/src/routes/-chatThreadRoute.logic.test.ts @@ -2,6 +2,7 @@ import { ThreadId, TurnId } from "@synara/contracts"; import { describe, expect, it } from "vitest"; import { + resolveDockDiffAvailable, resolveFilePreviewWorkspaceRoot, resolveRoutePanelBootstrap, resolveSplitPaneCloseDecision, @@ -59,6 +60,22 @@ describe("resolveFilePreviewWorkspaceRoot", () => { }); }); +describe("resolveDockDiffAvailable", () => { + it("keeps repository changes available before a thread has turn-scoped diffs", () => { + expect(resolveDockDiffAvailable({ turnDiffCount: 0, hasWorkingTreeChanges: true })).toBe(true); + }); + + it("uses turn-scoped diffs when the repository working tree is clean", () => { + expect(resolveDockDiffAvailable({ turnDiffCount: 1, hasWorkingTreeChanges: false })).toBe(true); + }); + + it("reports no diff only when both sources are empty", () => { + expect(resolveDockDiffAvailable({ turnDiffCount: 0, hasWorkingTreeChanges: false })).toBe( + false, + ); + }); +}); + describe("resolveRoutePanelBootstrap", () => { it("hydrates diff deep links exactly once per scope and search payload", () => { const first = resolveRoutePanelBootstrap({ diff --git a/apps/web/src/routes/-chatThreadRoute.logic.ts b/apps/web/src/routes/-chatThreadRoute.logic.ts index 71b3b0260..e016f2d44 100644 --- a/apps/web/src/routes/-chatThreadRoute.logic.ts +++ b/apps/web/src/routes/-chatThreadRoute.logic.ts @@ -63,6 +63,13 @@ export function resolveFilePreviewWorkspaceRoot(input: { }); } +export function resolveDockDiffAvailable(input: { + readonly turnDiffCount: number; + readonly hasWorkingTreeChanges: boolean; +}): boolean { + return input.turnDiffCount > 0 || input.hasWorkingTreeChanges; +} + function createRoutePanelSearchKey(input: { scopeId: string; search: DiffRouteSearch; diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index d54201ff1..be82b19f4 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -20,7 +20,7 @@ import { LOCAL_HTML_PREVIEW_ROUTE_PATH, isSupportedLocalHtmlPath, } from "@synara/shared/localPreviewFiles"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Suspense, @@ -49,6 +49,7 @@ import { } from "../components/DiffPanelShell"; import { useComposerDraftStore } from "../composerDraftStore"; import { useDockPaneRuntimeActivation } from "../hooks/useDockPaneRuntimeActivation"; +import { useDockWorkspaceExplorer } from "../components/chat/useDockWorkspaceExplorer"; import { type ChatRightPanel, type DiffRouteSearch, @@ -116,6 +117,7 @@ import { storeDockFileExplorerOpen, } from "../lib/dockFileExplorerPreference"; import { FoldersIcon } from "../lib/icons"; +import { passiveGitStatusQueryOptions } from "../lib/gitReactQuery"; import { projectListDirectoriesQueryOptions, projectLocalPreviewGrantQueryOptions, @@ -166,6 +168,7 @@ import { DialogTitle, } from "../components/ui/dialog"; import { + resolveDockDiffAvailable, resolveFilePreviewWorkspaceRoot, resolveRoutePanelBootstrap, resolveSplitPaneCloseDecision, @@ -1491,8 +1494,8 @@ function SingleChatSurface(props: { const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[props.threadId] ?? null, ); - const diffAvailable = useStore( - (store) => (store.turnDiffIdsByThreadId?.[props.threadId]?.length ?? 0) > 0, + const turnDiffCount = useStore( + (store) => store.turnDiffIdsByThreadId?.[props.threadId]?.length ?? 0, ); const newThreadLandingRef = useRef<{ threadId: ThreadIdType; deferMount: boolean } | null>(null); if (newThreadLandingRef.current?.threadId !== props.threadId) { @@ -1514,6 +1517,17 @@ function SingleChatSurface(props: { threadEnvMode: threadWorkspaceMetadata.envMode ?? draftThread?.envMode ?? null, threadWorktreePath: threadWorkspaceMetadata.worktreePath ?? draftThread?.worktreePath ?? null, }); + const gitStatusQuery = useQuery(passiveGitStatusQueryOptions(workspaceRoot)); + const diffAvailable = resolveDockDiffAvailable({ + turnDiffCount, + hasWorkingTreeChanges: gitStatusQuery.data?.hasWorkingTreeChanges ?? false, + }); + // Explorer state belongs to the chat surface, not an individual pane. The + // standalone explorer is replaced by a file pane on selection and inactive + // file panes unmount, so pane-local state would be lost on every transition. + const dockWorkspaceExplorer = useDockWorkspaceExplorer( + `${props.threadId}\u0000${workspaceRoot ?? ""}`, + ); const projects = useStore((store) => store.projects); const { settings: appSettings } = useAppSettings(); const { handleNewThread } = useHandleNewThread(); @@ -2243,6 +2257,7 @@ function SingleChatSurface(props: { Loading explorer...}> @@ -2255,6 +2270,7 @@ function SingleChatSurface(props: { workspaceRoot={workspaceRoot} filePath={pane.filePath} explorerOpen={dockFileExplorerOpen} + explorer={dockWorkspaceExplorer} onOpenFile={handleOpenDockFile} onReferenceInChat={handleReferenceInChat} onAskWhyInChat={handleAskWhyInChat} @@ -2296,6 +2312,7 @@ function SingleChatSurface(props: { handleOpenDockFile, handleReferenceInChat, dockFileExplorerOpen, + dockWorkspaceExplorer, props.projectId, props.threadId, requestActiveDockPaneLive, diff --git a/packages/shared/package.json b/packages/shared/package.json index 57805fbb3..b8bf1758c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,6 +8,10 @@ "types": "./src/model.ts", "import": "./src/model.ts" }, + "./messageOrder": { + "types": "./src/messageOrder.ts", + "import": "./src/messageOrder.ts" + }, "./git": { "types": "./src/git.ts", "import": "./src/git.ts" diff --git a/packages/shared/src/messageOrder.test.ts b/packages/shared/src/messageOrder.test.ts new file mode 100644 index 000000000..cac27dd5f --- /dev/null +++ b/packages/shared/src/messageOrder.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { compareProjectionMessageOrderValues } from "./messageOrder"; + +describe("compareProjectionMessageOrderValues", () => { + it("orders by timestamp before message id", () => { + expect( + compareProjectionMessageOrderValues( + "2026-07-23T00:00:00.000Z", + "message-z", + "2026-07-23T00:00:01.000Z", + "message-a", + ), + ).toBeLessThan(0); + }); + + it("uses the message id as the stable timestamp tie-break", () => { + const createdAt = "2026-07-23T00:00:00.000Z"; + expect( + compareProjectionMessageOrderValues(createdAt, "message-a", createdAt, "message-z"), + ).toBeLessThan(0); + expect( + compareProjectionMessageOrderValues(createdAt, "message-z", createdAt, "message-a"), + ).toBeGreaterThan(0); + }); +}); diff --git a/packages/shared/src/messageOrder.ts b/packages/shared/src/messageOrder.ts new file mode 100644 index 000000000..f18f723d5 --- /dev/null +++ b/packages/shared/src/messageOrder.ts @@ -0,0 +1,21 @@ +// FILE: messageOrder.ts +// Purpose: Match the persisted projection order for messages in every runtime. +// Layer: Shared orchestration ordering + +/** + * Projection queries order messages by SQLite's created_at/message_id keys. + * Keep browser and server command validation on the same deterministic order, + * including when multiple live events share a timestamp. + */ +export function compareProjectionMessageOrderValues( + leftCreatedAt: string, + leftMessageId: string, + rightCreatedAt: string, + rightMessageId: string, +): number { + if (leftCreatedAt < rightCreatedAt) return -1; + if (leftCreatedAt > rightCreatedAt) return 1; + if (leftMessageId < rightMessageId) return -1; + if (leftMessageId > rightMessageId) return 1; + return 0; +} From 9642ac16239ae0138d5e47eff8ee63b041a5de09 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 04:20:51 +0300 Subject: [PATCH 2/2] Fix long transcript fork validation --- .../Layers/OrchestrationEngine.test.ts | 5 +++-- apps/server/src/orchestration/decider.ts | 2 +- apps/server/src/orchestration/messageFork.ts | 13 ++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 24889808a..5ed7720d1 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -376,7 +376,7 @@ describe("OrchestrationEngine", () => { } }); - it("validates message-boundary forks against transcripts beyond the UI message cap", async () => { + it("validates long message-boundary forks against the renderer-retained source window", async () => { const system = await createOrchestrationSystem(); const { engine } = system; const projectId = asProjectId("project-long-fork"); @@ -447,7 +447,7 @@ describe("OrchestrationEngine", () => { envMode: "local", branch: null, worktreePath: null, - importedMessages: sourceMessages.map((message, index) => ({ + importedMessages: sourceMessages.slice(-2_000).map((message, index) => ({ ...message, messageId: asMessageId(`long-fork-${String(index).padStart(4, "0")}`), })), @@ -460,6 +460,7 @@ describe("OrchestrationEngine", () => { (thread) => thread.id === ThreadId.makeUnsafe("thread-long-fork-destination"), ); expect(destination?.messages).toHaveLength(2_000); + expect(destination?.messages[0]?.text).toBe("Long transcript message 0002"); expect(destination?.messages.at(-1)?.text).toBe("Long transcript message 2001"); } finally { await system.dispose(); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5471ed9ee..a25881e61 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -663,7 +663,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if (!validation.ok) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `Message-boundary fork through '${command.sourceMessageId}' must import the exact ${validation.expectedImportedMessageCount} completed conversation messages from the authoritative source prefix.`, + detail: `Message-boundary fork through '${command.sourceMessageId}' must import the exact ${validation.expectedImportedMessageCount} completed conversation messages from the authoritative retained source window.`, }); } } diff --git a/apps/server/src/orchestration/messageFork.ts b/apps/server/src/orchestration/messageFork.ts index 40c56b6b2..fea22a750 100644 --- a/apps/server/src/orchestration/messageFork.ts +++ b/apps/server/src/orchestration/messageFork.ts @@ -29,6 +29,11 @@ export interface ImportedMessageIdValidation { type MessageForkSourceThread = Pick; +// The renderer intentionally retains only the newest 2,000 message rows. Fork +// validation loads uncapped persistence so it can derive that same authoritative +// window instead of requiring a prefix the production client cannot construct. +const MESSAGE_FORK_SOURCE_WINDOW_MAX_MESSAGES = 2_000; + function resolveRunningTurnBoundaryIndex(thread: MessageForkSourceThread): number | null { if (thread.latestTurn?.state !== "running") { return null; @@ -151,6 +156,8 @@ export function validateMessageForkImport(input: { if ( sourceMessageIndex < 0 || !sourceMessage || + sourceMessageIndex < + Math.max(0, input.sourceThread.messages.length - MESSAGE_FORK_SOURCE_WINDOW_MAX_MESSAGES) || !isCompletedConversationMessage( input.sourceThread, sourceMessage, @@ -165,8 +172,12 @@ export function validateMessageForkImport(input: { }; } + const sourceWindowStartIndex = Math.max( + 0, + input.sourceThread.messages.length - MESSAGE_FORK_SOURCE_WINDOW_MAX_MESSAGES, + ); const conversationPrefix = input.sourceThread.messages - .slice(0, sourceMessageIndex + 1) + .slice(sourceWindowStartIndex, sourceMessageIndex + 1) .filter( (message): message is OrchestrationMessage & { readonly role: "user" | "assistant" } => message.role === "user" || message.role === "assistant",