From dfc70a3294641a2acb021766a759ac4c79665944 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:02:19 -0700 Subject: [PATCH 001/103] test(contracts): keep driver default lookup private (#9968) --- packages/contracts/src/settings.test.ts | 13 ++++--------- packages/contracts/src/settings.ts | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c5d9c52a7175..1673f4ad159b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,7 +7,6 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, - defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -432,14 +431,6 @@ describe("provider enabled defaults", () => { expect(decoded.providers.opencode.enabled).toBe(false); }); - it("derives per-driver defaults from the settings schemas", () => { - expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); - expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); - expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); - // Unknown fork drivers stay enabled; their own build decides otherwise. - expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); - }); - it("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); @@ -460,6 +451,10 @@ describe("provider enabled defaults", () => { // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); + // Unknown fork drivers stay enabled. + expect( + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), + ).toBe(true); // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cf68dcf62de8..50923423352b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -975,7 +975,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays * the single source of truth. Unknown (fork) drivers default to enabled. */ -export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined From 32142cff186c607c8cae644927cee8d6c9cba757 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:05:07 -0700 Subject: [PATCH 002/103] test(server): remove Azure permissions constant snapshot (#9973) --- .../AzureDevOpsPullRequestProvider.test.ts | 29 ------------------- .../AzureDevOpsPullRequestProvider.ts | 2 +- 2 files changed, 1 insertion(+), 30 deletions(-) delete mode 100644 apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted file mode 100644 index 51d8f74bbc45..000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { AZURE_DEVOPS_VIEWER_PERMISSIONS } from "./AzureDevOpsPullRequestProvider.ts"; - -describe("azure devops viewer permissions", () => { - it("offers every action to whoever is signed in, because Azure names no permission", () => { - // The same answer for a viewer who can write, one who can only read, and an author with read - // access: `az repos pr show` and `az repos pr list` carry nothing about the caller's standing, - // and an unknown permission is granted rather than guessed away. Azure refuses the ones it - // will not allow, at the moment they are taken, in words this could not have written. - expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], - // False because the host itself cannot post one, not because this viewer may not. - comment: false, - resolve: false, - verdicts: [], - // True because `az repos pr reviewer` does take one, and Azure says nothing about who may. - requestReviewers: true, - }); - }); -}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 8461e57d5685..3d501a32d61b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -56,7 +56,7 @@ const CAPABILITIES: PullRequestCapabilities = { * they try. That is the safer half of an unknown: hiding a control from someone entitled to it * leaves them no way through and no reason given. */ -export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { +const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { actions: CAPABILITIES.actions, comment: CAPABILITIES.comment, resolve: CAPABILITIES.review.resolve, From 1568b3fd083e198a08bb12353820c1ef99fb3420 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:23 -0700 Subject: [PATCH 003/103] refactor(shared): remove unused viewport formatters (#9970) --- packages/shared/src/previewViewport.test.ts | 15 +-------------- packages/shared/src/previewViewport.ts | 11 ----------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts index 3222e90d7be5..7a049376c50e 100644 --- a/packages/shared/src/previewViewport.test.ts +++ b/packages/shared/src/previewViewport.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - PREVIEW_VIEWPORT_PRESETS, - previewViewportLabel, - previewViewportPresetOrientation, - resolvePreviewViewport, -} from "./previewViewport.ts"; +import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "./previewViewport.ts"; describe("previewViewport", () => { it("resolves fill and exact freeform viewports", () => { @@ -59,12 +54,4 @@ describe("previewViewport", () => { "Nest Hub Max", ]); }); - - it("formats settings for compact UI", () => { - expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel"); - expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852"); - expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe( - "landscape", - ); - }); }); diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts index 1d70bca5dfbd..d1e066bee16d 100644 --- a/packages/shared/src/previewViewport.ts +++ b/packages/shared/src/previewViewport.ts @@ -173,14 +173,3 @@ export function resolvePreviewViewport( height: input.height, }; } - -export function previewViewportLabel(viewport: PreviewViewportSetting): string { - return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`; -} - -export function previewViewportPresetOrientation( - viewport: PreviewViewportSetting, -): "portrait" | "landscape" | null { - if (viewport._tag === "fill" || viewport.width === viewport.height) return null; - return viewport.width > viewport.height ? "landscape" : "portrait"; -} From c1e279eca52e02ee3fb9c8d133057a30526f282c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:25 -0700 Subject: [PATCH 004/103] refactor(mobile): remove unused provider option summary (#9971) --- apps/mobile/src/lib/providerOptions.test.ts | 18 ++---------------- apps/mobile/src/lib/providerOptions.ts | 18 ------------------ 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d87df6baaf1d..9b94cecb3db9 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; -import { - applyProviderOptionSelection, - providerOptionValueLabels, - resolveProviderOptionDescriptors, -} from "./providerOptions"; +import { applyProviderOptionSelection, resolveProviderOptionDescriptors } from "./providerOptions"; const CODEX_CAPABILITIES: ModelCapabilities = { optionDescriptors: [ @@ -34,15 +30,6 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("summarizes the option values currently in effect", () => { - const descriptors = resolveProviderOptionDescriptors({ - capabilities: CODEX_CAPABILITIES, - selections: undefined, - }); - - expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); - }); - it("updates generic select options without knowing provider-specific ids", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, @@ -62,7 +49,7 @@ describe("mobile provider options", () => { expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); - it("treats an unspecified boolean capability as off", () => { + it("updates generic boolean options", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: { optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], @@ -70,7 +57,6 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(providerOptionValueLabels(descriptors)).toEqual([]); expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ { id: "fastMode", value: true }, ]); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index 593f5a37442c..dec0d327030d 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, - getProviderOptionCurrentLabel, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -22,23 +21,6 @@ export function resolveProviderOptionDescriptors(input: { }); } -/** - * Labels for the option values currently in effect (select values plus - * enabled booleans), used to summarize the thread configuration in the - * composer trigger pill. - */ -export function providerOptionValueLabels( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.flatMap((descriptor) => { - if (descriptor.type === "boolean") { - return descriptor.currentValue ? [descriptor.label] : []; - } - const label = getProviderOptionCurrentLabel(descriptor); - return label ? [label] : []; - }); -} - /** * Applies one option change (by descriptor id) and returns the full selection * list to store on the model selection, or null when the change doesn't match From 1782a2af44a7630f184a05dbcf788e6121da2554 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:26 -0700 Subject: [PATCH 005/103] refactor(client-runtime): remove unused connection phase message (#9972) --- .../src/connection/presentation.test.ts | 5 ---- .../src/connection/presentation.ts | 24 +------------------ 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e13638a2b41f..80ce8a374a9c 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -10,7 +10,6 @@ import { } from "./model.ts"; import { connectionCatalogDisplayUrl, - connectionPhaseMessage, connectionStatusText, connectionStatusTitle, presentEnvironmentConnection, @@ -119,10 +118,6 @@ describe("connection presentation", () => { }); }); - it("gives offline status precedence in global messaging", () => { - expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); - }); - it("combines reconnect progress with the latest failure", () => { const connection = { phase: "reconnecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 168443deceb4..4093167d333c 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,7 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; +import type { SupervisorConnectionState } from "./model.ts"; export type EnvironmentConnectionPhase = | "available" @@ -105,25 +105,3 @@ export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): stri : null; } } - -export function connectionPhaseMessage( - phase: EnvironmentConnectionPhase, - label: string, - networkStatus: NetworkStatus, -): string { - if (networkStatus === "offline" || phase === "offline") { - return "You are offline"; - } - switch (phase) { - case "available": - return "Available"; - case "connecting": - return `Connecting to ${label}...`; - case "reconnecting": - return `Reconnecting to ${label}...`; - case "connected": - return "Connected"; - case "error": - return "Connection failed"; - } -} From eda0cec92398a4097958b55accaa59fdd178a977 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:42 -0700 Subject: [PATCH 006/103] refactor(mobile): remove unused layout calculations (#9974) --- apps/mobile/src/lib/layout.test.ts | 19 --------------- apps/mobile/src/lib/layout.ts | 38 ------------------------------ 2 files changed, 57 deletions(-) diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 8342fd1aeebc..7d288b1352a5 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { constrainAuxiliaryPaneWidth, - constrainPrimarySidebarWidth, deriveCenteredContentHorizontalPadding, deriveFileInspectorPaneLayout, deriveLayout, - deriveStableFormSheetDetent, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -75,12 +73,6 @@ describe("deriveThreadFeedInitialContentInset", () => { }); describe("resizable pane constraints", () => { - it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { - expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); - expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); - expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); - }); - it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); @@ -392,14 +384,3 @@ describe("deriveWorkspacePaneLayout", () => { }); }); }); - -describe("deriveStableFormSheetDetent", () => { - it.each([ - { height: 1_194, expected: 0.62 }, - { height: 834, expected: 0.863 }, - { height: 600, expected: 0.893 }, - { height: 0, expected: 0.92 }, - ])("derives a stable sheet detent for height $height", ({ height, expected }) => { - expect(deriveStableFormSheetDetent(height)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 4199dc8dc8ed..ee38ac020e74 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -16,7 +16,6 @@ export const SPLIT_LAYOUT_MIN_WIDTH = 720; export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280; -export const SPLIT_SIDEBAR_MAX_WIDTH = 460; const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; @@ -50,10 +49,6 @@ export const AUXILIARY_PANE_MAX_WIDTH = 480; const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; -const STABLE_FORM_SHEET_MAX_HEIGHT = 720; -const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; -const STABLE_FORM_SHEET_MIN_DETENT = 0.62; -const STABLE_FORM_SHEET_MAX_DETENT = 0.92; export type LayoutVariant = "compact" | "split"; @@ -218,22 +213,6 @@ export function deriveFileInspectorPaneLayout(input: { }; } -/** Keep a user-selected sidebar width useful as a window is resized. */ -export function constrainPrimarySidebarWidth( - preferredWidth: number, - viewportWidth = Number.POSITIVE_INFINITY, -): number { - const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; - const viewportMax = Number.isFinite(viewportWidth) - ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) - : SPLIT_SIDEBAR_MAX_WIDTH; - return clamp( - Math.round(safeWidth), - SPLIT_SIDEBAR_MIN_WIDTH, - Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), - ); -} - /** * Keep an auxiliary pane within native-feeling bounds without squeezing its * neighboring content below a usable reading/editor width. @@ -275,20 +254,3 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } - -export function deriveStableFormSheetDetent(containerHeight: number): number { - if (!Number.isFinite(containerHeight) || containerHeight <= 0) { - return STABLE_FORM_SHEET_MAX_DETENT; - } - - const targetHeight = Math.min( - STABLE_FORM_SHEET_MAX_HEIGHT, - Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), - ); - const detent = clamp( - targetHeight / containerHeight, - STABLE_FORM_SHEET_MIN_DETENT, - STABLE_FORM_SHEET_MAX_DETENT, - ); - return Math.round(detent * 1_000) / 1_000; -} From 044a6e168ad1eb213c2c7c277034e65c638cc937 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:44 -0700 Subject: [PATCH 007/103] refactor(client-runtime): remove unused file position predicate (#9976) --- packages/client-runtime/src/markdownLinks.test.ts | 10 ---------- packages/client-runtime/src/markdownLinks.ts | 9 --------- 2 files changed, 19 deletions(-) diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 42cd7a35e473..4aea947b87d6 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { fileBasename, inlineCodeFilePathCandidate, - isConventionalFilePosition, parseFileUrlHref, parseMarkdownFileLink, splitFilePathPosition, @@ -28,15 +27,6 @@ describe("inlineCodeFilePathCandidate", () => { }); }); -describe("isConventionalFilePosition", () => { - it("distinguishes extensionless file locations from labels and ports", () => { - expect(isConventionalFilePosition("Dockerfile:8:2")).toBe(true); - expect(isConventionalFilePosition("Makefile")).toBe(false); - expect(isConventionalFilePosition("TODO:12")).toBe(false); - expect(isConventionalFilePosition("port:3000")).toBe(false); - }); -}); - describe("parseFileUrlHref", () => { it.each([ ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 2e455655d005..29a337e49c42 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -15,7 +15,6 @@ const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; -const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ @@ -152,14 +151,6 @@ function looksLikeHostname(segment: string, hasPosition: boolean): boolean { return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); } -/** Recognizes conventional extensionless filenames with an explicit line position. */ -export function isConventionalFilePosition(path: string): boolean { - return ( - BARE_EXTENSIONLESS_POSITION_PATTERN.test(path) && - EXTENSIONLESS_FILE_NAMES.has(path.replace(POSITION_SUFFIX_PATTERN, "")) - ); -} - /** * Picks path-shaped inline code for the client's markdown file-link resolver. * It does not resolve paths or turn plain prose and fenced code into links. From 5fe29c89456c151529a63ddcbf132b4c518f1838 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:21 -0700 Subject: [PATCH 008/103] refactor(mobile): remove unused font size steppers (#9975) --- apps/mobile/src/lib/appearancePreferences.test.ts | 7 +------ apps/mobile/src/lib/appearancePreferences.ts | 10 ---------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 3458f0120f99..af09637b4e9f 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -13,8 +13,6 @@ import { resolveMobileCodeSurface, resolveNativeMarkdownTypography, resolveTextScaleVariables, - stepBaseFontSize, - stepCodeFontSize, stepTerminalFontSize, } from "./appearancePreferences"; @@ -73,11 +71,8 @@ describe("appearancePreferences", () => { expect(normalizeCodeFontSize(30)).toBe(18); }); - it("steps font sizes within bounds", () => { + it("steps terminal font size within bounds", () => { expect(stepTerminalFontSize(6, -1)).toBe(6); - expect(stepBaseFontSize(11, -1)).toBe(11); - expect(stepCodeFontSize(8, -1)).toBe(8); - expect(stepBaseFontSize(15, 1)).toBe(16); }); it("scales markdown typography from the base size", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index d2504a629dda..b81c50056543 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -235,22 +235,12 @@ export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMar }; } -export function stepBaseFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP; - return normalizeBaseFontSize(next); -} - export function stepTerminalFontSize(current: number, direction: -1 | 1): number { const next = direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; return normalizeTerminalFontSize(next); } -export function stepCodeFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP; - return normalizeCodeFontSize(next); -} - export { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, From b3f8dd979af2bb089f94e6f1006ebbb2cf748db4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:26 -0700 Subject: [PATCH 009/103] test(server): cover thread lookup through command invariants (#9978) --- .../src/orchestration/commandInvariants.test.ts | 11 ++--------- apps/server/src/orchestration/commandInvariants.ts | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9aaeba943423..93777c67d3e1 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -11,12 +11,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { - findThreadById, - listThreadsByProjectId, - requireThread, - requireThreadAbsent, -} from "./commandInvariants.ts"; +import { listThreadsByProjectId, requireThread, requireThreadAbsent } from "./commandInvariants.ts"; const now = "2026-01-01T00:00:00.000Z"; @@ -121,9 +116,7 @@ const messageSendCommand: OrchestrationCommand = { }; describe("commandInvariants", () => { - it("finds threads by id and project", () => { - expect(findThreadById(readModel, ThreadId.make("thread-1"))?.projectId).toBe("project-a"); - expect(findThreadById(readModel, ThreadId.make("missing"))).toBeUndefined(); + it("lists threads by project", () => { expect( listThreadsByProjectId(readModel, ProjectId.make("project-b")).map((thread) => thread.id), ).toEqual([ThreadId.make("thread-2")]); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index beaad93d5eef..110a499d37c9 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -18,7 +18,7 @@ function invariantError(commandType: string, detail: string): OrchestrationComma }); } -export function findThreadById( +function findThreadById( readModel: OrchestrationReadModel, threadId: ThreadId, ): OrchestrationThread | undefined { From 2fc630c9e85321b7b3963372fd316877a7581639 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:30 -0700 Subject: [PATCH 010/103] test(server): remove provider equality wrapper fixture (#9979) --- .../provider/Layers/ProviderRegistry.test.ts | 34 ------------------- .../src/provider/Layers/ProviderRegistry.ts | 2 +- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cf3fe15ea9a5..ffabf6d8d4f3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -43,7 +43,6 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { - haveProvidersChanged, mergeProviderSnapshot, upsertProviderWorkspaceSnapshot, ProviderRegistryLive, @@ -555,39 +554,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); - it("stores workspace skills and commands without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2fd4278f3c57..94dde9f931bb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -188,7 +188,7 @@ export const mergeProviderSnapshot = ( : {}), }; -export const haveProvidersChanged = ( +const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); From 1550f1b742138df8b31b24e34ddea58c80afb3cc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:34 -0700 Subject: [PATCH 011/103] test(server): assert the dispatched welcome thread model (#9980) --- apps/server/src/serverRuntimeStartup.test.ts | 20 ++++++++------------ apps/server/src/serverRuntimeStartup.ts | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b53e1843c226..0b909d96f43d 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -17,13 +17,6 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { - assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); - it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => Effect.gen(function* () { const pulled: string[] = []; @@ -201,7 +194,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -298,10 +294,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when ["project.create", "thread.create"], ); assert.equal("defaultModelSelection" in commands[0]!, false); - assert.deepStrictEqual( - commands[1]?.modelSelection, - ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), - ); + assert.deepStrictEqual(commands[1]?.modelSelection, { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index a34d1bdbde91..7a5e7b12b865 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -176,7 +176,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( Effect.asVoid, ); -export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); From 14da634577773d291c494635c1a4bcdb0e70f9ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:39 -0700 Subject: [PATCH 012/103] refactor(desktop): remove unused keyring remediation text (#9981) --- apps/desktop/src/linuxSecretStorage.test.ts | 77 ---------------- apps/desktop/src/linuxSecretStorage.ts | 99 --------------------- 2 files changed, 176 deletions(-) diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a91790200771..5827e38e406f 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index fe3e21eadb92..3aa7a440d1e8 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; -} - -function getKWalletRemediationMessage(): string { - return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} From a9caf7b7089afb6f1b3b888ea404b75b8f9e88e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:43 -0700 Subject: [PATCH 013/103] refactor(desktop): remove test-only Electron error predicates (#9982) --- apps/desktop/src/electron/ElectronDialog.test.ts | 1 - apps/desktop/src/electron/ElectronDialog.ts | 1 - apps/desktop/src/electron/ElectronTheme.test.ts | 1 - apps/desktop/src/electron/ElectronTheme.ts | 2 -- apps/desktop/src/electron/ElectronUpdater.test.ts | 3 --- apps/desktop/src/electron/ElectronUpdater.ts | 1 - apps/desktop/src/electron/ElectronWindow.test.ts | 1 - apps/desktop/src/electron/ElectronWindow.ts | 2 -- 8 files changed, 12 deletions(-) diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 3acaf7154508..2ed5a1f2f913 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -43,7 +43,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 4300d9ab0d39..30ca73a5e143 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -73,7 +73,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff2b..b4028930af66 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d0954f..24b2d856b9d2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df8..8e044de65ad6 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index bebb0e5c4178..c802e595633a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "T3 Code", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d34280b..9234399191cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowOperationError", { From ea0487cc9adf9f51c865fe39128102afc532bcfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:47 -0700 Subject: [PATCH 014/103] refactor(web): remove unused pull request state label (#9984) --- .../pullRequest/pullRequestDetail.logic.test.ts | 10 ---------- .../components/pullRequest/pullRequestDetail.logic.ts | 7 ------- 2 files changed, 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index c51429ff6d83..61c630c815e4 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -38,7 +38,6 @@ import { shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, - describePullRequestState, editPullRequestThreadComment, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; @@ -199,15 +198,6 @@ describe("pull request primary control", () => { }); }); -describe("pull request state description", () => { - it("keeps draft and conflicts orthogonal to the terminal states", () => { - expect(describePullRequestState("open", true)).toBe("Draft"); - expect(describePullRequestState("open", false)).toBe("Ready for review"); - expect(describePullRequestState("merged", true)).toBe("Merged"); - expect(describePullRequestState("closed", false)).toBe("Closed"); - }); -}); - describe("pull request handoff labels", () => { it("names the open thread when actions write to its composer", () => { expect(pullRequestHandoffLabels(true)).toEqual({ diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index cffe33f8d83d..d00215f02d41 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -189,13 +189,6 @@ export function isStackedPullRequestBase( return defaultBranch !== baseBranch; } -/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ -export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return isDraft ? "Draft" : "Ready for review"; -} - /** Chronological ascending, oldest to newest — reversed for the "newest" reading order. */ export function orderPullRequestComments( comments: ReadonlyArray, From aefef95ffc34eb76c8e2db9a219b86681f644576 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:28 -0700 Subject: [PATCH 015/103] perf(web): keep timeline row reuse engaged while text streams (#9909) Co-authored-by: Claude Fable 5 --- .../web/src/components/ChatView.logic.test.ts | 50 ++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 30 ++++++---- apps/web/src/components/ChatView.tsx | 26 +++++---- .../chat/MessagesTimeline.logic.test.ts | 58 ++++++++++++++++++- 4 files changed, 141 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c67ea3f3d5c3..a5a8503985b2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1089,6 +1089,56 @@ describe("buildRevertTurnCountByUserMessageId", () => { }).size, ).toBe(0); }); + + it.each([true, false])( + "returns the previous map when contents are unchanged (rollback supported: %s)", + (supportsConversationRollback) => { + const input = { + supportsConversationRollback, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }; + const previous = buildRevertTurnCountByUserMessageId(input); + const streamed = timelineEntries.map((entry) => + entry.message.role === "assistant" + ? { ...entry, message: { ...entry.message, text: "Updated the file again" } } + : entry, + ); + + expect( + buildRevertTurnCountByUserMessageId({ ...input, timelineEntries: streamed }, previous), + ).toBe(previous); + }, + ); + + it("returns a new map when a revert target changes", () => { + const input = { + supportsConversationRollback: true, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }; + const previous = buildRevertTurnCountByUserMessageId(input); + const next = buildRevertTurnCountByUserMessageId( + { + ...input, + turnDiffSummaryByAssistantMessageId: new Map([ + [ + assistantMessageId, + { + ...turnDiffSummaryByAssistantMessageId.get(assistantMessageId)!, + checkpointTurnCount: 3, + }, + ], + ]), + }, + previous, + ); + + expect(next).not.toBe(previous); + expect(next).toEqual(new Map([[userMessageId, 2]])); + }); }); describe("deriveComposerSendState", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 10c2fd4710ee..bf576a3c7635 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,6 +38,7 @@ import { } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; +import { shallow } from "zustand/vanilla/shallow"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; import { @@ -464,17 +465,24 @@ export function getAntigravitySendBlockReason( return null; } -export function buildRevertTurnCountByUserMessageId(input: { - supportsConversationRollback: boolean; - timelineEntries: ReadonlyArray; - turnDiffSummaryByAssistantMessageId: ReadonlyMap; - inferredCheckpointTurnCountByTurnId: Readonly>; -}) { +/** + * Maps each user message to the checkpoint turn count a revert should target. + * Returns `previous` when the result is unchanged: streaming text deltas + * rebuild `timelineEntries` per token, and the timeline row projection only + * reuses rows while this Map keeps its identity. + */ +export function buildRevertTurnCountByUserMessageId( + input: { + supportsConversationRollback: boolean; + timelineEntries: ReadonlyArray; + turnDiffSummaryByAssistantMessageId: ReadonlyMap; + inferredCheckpointTurnCountByTurnId: Readonly>; + }, + previous: Map | null = null, +): Map { const byUserMessageId = new Map(); - if (!input.supportsConversationRollback) { - return byUserMessageId; - } - for (let index = 0; index < input.timelineEntries.length; index += 1) { + const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; + for (let index = 0; index < entryCount; index += 1) { const entry = input.timelineEntries[index]; if (!entry || entry.kind !== "message" || entry.message.role !== "user") { continue; @@ -501,7 +509,7 @@ export function buildRevertTurnCountByUserMessageId(input: { break; } } - return byUserMessageId; + return previous !== null && shallow(previous, byUserMessageId) ? previous : byUserMessageId; } export function reconcileMountedTerminalThreadIds(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4ceba195e98b..b27d66c7611d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2971,21 +2971,25 @@ export default function ChatView(props: ChatViewProps) { } return byMessageId; }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo( - () => - buildRevertTurnCountByUserMessageId({ + const lastRevertTurnCountRef = useRef | null>(null); + const revertTurnCountByUserMessageId = useMemo(() => { + const next = buildRevertTurnCountByUserMessageId( + { supportsConversationRollback, timelineEntries, turnDiffSummaryByAssistantMessageId, inferredCheckpointTurnCountByTurnId, - }), - [ - supportsConversationRollback, - inferredCheckpointTurnCountByTurnId, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - ], - ); + }, + lastRevertTurnCountRef.current, + ); + lastRevertTurnCountRef.current = next; + return next; + }, [ + supportsConversationRollback, + inferredCheckpointTurnCountByTurnId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ]); const gitCwd = activeProject ? projectScriptCwd({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 94727086f186..5d310c5fe345 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { MessageId, TurnId } from "@t3tools/contracts"; +import { CheckpointRef, MessageId, TurnId } from "@t3tools/contracts"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, @@ -20,6 +20,7 @@ import { deriveTimelineEntriesWithState, type WorkLogEntry, } from "../../session-logic"; +import { buildRevertTurnCountByUserMessageId } from "../ChatView.logic"; import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; describe("streaming row projection", () => { @@ -242,6 +243,61 @@ describe("streaming row projection", () => { }, ); + it("reuses rows when the revert map is rebuilt from the streamed entries", () => { + const initial = fixture("Partial"); + const inferredCheckpointTurnCountByTurnId = { [initial.historyTurnId]: 1 }; + const turnDiffSummaryByAssistantMessageId = new Map([ + [ + MessageId.make("history-assistant"), + { + turnId: initial.historyTurnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("history-assistant"), + completedAt: initial.time(4), + }, + ], + ]); + let revertMap: Map | null = null; + // Mirrors ChatView: the map is derived from each delta's entries. + const build = (timelineEntries: typeof initial.timeline.entries) => { + revertMap = buildRevertTurnCountByUserMessageId( + { + supportsConversationRollback: true, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId, + }, + revertMap, + ); + return { + ...initial.input, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + revertTurnCountByUserMessageId: revertMap, + }; + }; + const previous = deriveMessagesTimelineRowsWithState(build(initial.timeline.entries)); + expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe( + true, + ); + const last = initial.messages.at(-1)!; + const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }]; + const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); + const next = deriveMessagesTimelineRowsWithState(build(timeline.entries), previous); + + expect(next.rows).toEqual(deriveMessagesTimelineRows(build(timeline.entries))); + for (const [index, row] of previous.rows.entries()) { + if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) { + expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } }); + } else { + expect(next.rows[index]).toBe(row); + } + } + }); + it.each(["completion", "turn", "role", "ordering"] as const)( "rebuilds row structure for a %s change with otherwise unchanged controls", (change) => { From 3e1333319aacff96856a3c161b8787ff6b0ddd6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:32 -0700 Subject: [PATCH 016/103] fix(web): reset markdown widgets when the previewed file changes (#9910) Co-authored-by: Claude Fable 5 --- apps/web/src/components/files/FilePreviewPanel.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 3f0c92742ca7..33d4d9a4b6cf 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1266,7 +1266,11 @@ export default function FilePreviewPanel({ ) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( + // Markdown reconciles in place across text updates, so a file + // switch needs a new key or the previous file's disclosure and + // wrap state carries into the next document. Date: Sat, 5 Sep 2026 00:15:36 -0700 Subject: [PATCH 017/103] fix(mobile): keep highlighting review diffs after a long line (#9911) Co-authored-by: Claude Fable 5 --- .../diffs/nativeReviewDiffHighlighter.test.ts | 36 +++++++++++++++---- .../diffs/nativeReviewDiffHighlighter.ts | 17 ++++----- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index 00679afa4a63..e7e75a4faa1a 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -257,13 +257,13 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); }); - it("keeps long lines and unknown following syntax plain until the next hunk", async () => { + it("keeps only the long line plain and resumes highlighting after it", async () => { const longLine = `${"x".repeat(1_001)} /*`; const rows = [ line(1, "export const before = 1;"), line(2, longLine), { kind: "comment", id: "note", commentText: "Check this", fileId: TYPESCRIPT_FILE.id }, - line(3, "inside the comment */"), + line(3, "export const inside = 'x';"), makeHunk("next-hunk"), line(100, "export const after = 2;"), ] satisfies ReadonlyArray; @@ -274,14 +274,36 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-2"]).toEqual([ { content: longLine, color: null, fontStyle: null }, ]); - expect(result.tokensByRowId["line-3"]).toEqual([ - { content: "inside the comment */", color: null, fontStyle: null }, - ]); - expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); - expect(result.tokensByRowId["line-100"]?.some((token) => token.color !== null)).toBe(true); + for (const id of ["line-1", "line-3", "line-100"]) { + expect(result.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + } expect(tokenization.calls.some((code) => code.includes(longLine))).toBe(false); }); + it("highlights the rows after a long line the same regardless of the first window", async () => { + const rows = [ + line(1, "export const before = 1;"), + line(2, `const data = "${"x".repeat(1_050)}";`), + line(3, "export const inside = 'x';"), + line(4, "export const after = 2;"), + ]; + const spanning = await highlightRows(rows); + const afterLongLine = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine, + firstRowIndex: 2, + lastRowIndex: 3, + overscanRows: 0, + }); + + for (const id of ["line-3", "line-4"]) { + expect(spanning.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + expect(afterLongLine.tokensByRowId[id]).toEqual(spanning.tokensByRowId[id]); + } + }); + it("preserves multiline grammar and row mapping across character-limited batches", async () => { const opening = line(1, "const message = `open"); const body = Array.from({ length: 40 }, (_, index) => line(index + 2, "inside ".repeat(45))); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 383e1e73a85f..0ea8c100dc11 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -247,15 +247,16 @@ function createHighlighterHandle( while (start < lines.length) { if (signal?.aborted) return []; - // Skipping this line leaves its ending grammar state unknown. Keep the - // rest of this contiguous segment plain instead of guessing its syntax. + // Skipping this line leaves its ending grammar state unknown. Resume + // from a fresh state rather than leaving the rest of the segment plain: + // highlighted rows are cached for the sheet's lifetime, so a plain tail + // would stick, and which rows it covered would depend on where the + // first visible window happened to start. if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) { - highlighted.push( - ...lines - .slice(start) - .map((content) => [{ content: content || " ", color: null, fontStyle: null }]), - ); - break; + highlighted.push([{ content: lines[start] || " ", color: null, fontStyle: null }]); + grammarState = undefined; + start += 1; + continue; } let end = start; From fc1f543d6c67da9cf1edb25b647646faa0105b41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:41 -0700 Subject: [PATCH 018/103] fix(marketing): align the endorsement carousel with its heading (#9912) Co-authored-by: Claude Fable 5 --- apps/marketing/src/pages/index.astro | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index a4fdc966b9d8..723ad4d4324b 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -826,8 +826,11 @@ const screenshot = await getImage({ gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; - padding: 4px max(32px, calc((100vw - 1240px) / 2 + 32px)); - scroll-padding-inline: max(32px, calc((100vw - 1240px) / 2 + 32px)); + /* Percentages resolve against this element's own box rather than the + viewport, so the first card lines up with the heading's .container edge + even when a classic scrollbar makes 100vw wider than the layout. */ + padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px)); + scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px)); } .endorsement-card { From f87ecf0cc307f974745da8eea856fab5711023f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:46 -0700 Subject: [PATCH 019/103] fix(client): keep warm thread resumes live instead of flashing sync (#9913) Co-authored-by: Claude Fable 5 --- .../src/state/threads-atoms.test.ts | 91 ++++++++++++++++++- packages/client-runtime/src/state/threads.ts | 24 ++++- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index d7a415b2999d..27229a7aff61 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -27,6 +27,7 @@ import { PrimaryConnectionTarget, type NetworkStatus, type PreparedConnection, + type SupervisorConnectionState, } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; @@ -73,8 +74,18 @@ const THREAD: OrchestrationThread = { }; const SNAPSHOT: OrchestrationThreadDetailSnapshot = { snapshotSequence: 7, thread: THREAD }; +const CONNECTED_STATE: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, +}; + const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: { readonly snapshot?: OrchestrationThreadDetailSnapshot; + readonly connected?: boolean; }) { const subscriptions = yield* Queue.unbounded<{ readonly afterSequence: number | undefined; @@ -123,10 +134,14 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? probe: Effect.void, closed: Effect.never, }; + const connectionState = yield* SubscriptionRef.make( + options?.connected ? CONNECTED_STATE : AVAILABLE_CONNECTION_STATE, + ); + const sessionRef = yield* SubscriptionRef.make(Option.some(session)); const supervisor = EnvironmentSupervisor.of({ target: TARGET, - state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), - session: yield* SubscriptionRef.make(Option.some(session)), + state: connectionState, + session: sessionRef, prepared: yield* SubscriptionRef.make>( Option.some({ environmentId: TARGET.environmentId, @@ -228,6 +243,9 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? ref, subscriptions, olderLoads, + connectionState, + session, + sessionRef, counts: () => ({ httpLoads, diskLoads, opened, active }), }; }); @@ -319,6 +337,75 @@ describe("createEnvironmentThreadStateAtoms", () => { }), ); + it.effect.each([ + { replayed: false, statuses: ["live"] }, + { replayed: true, statuses: ["live", "synchronizing", "live"] }, + ])( + "keeps a warm resume live until it replays events (replayed: $replayed)", + ({ replayed, statuses }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + const observed: Array = []; + const stop = h.registry.subscribe(h.stateAtom, (state) => observed.push(state.status), { + immediate: true, + }); + const remount = h.registry.mount(h.stateAtom); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + if (replayed) { + yield* Queue.offer(next.events, { + kind: "snapshot", + snapshot: { snapshotSequence: 9, thread: { ...THREAD, title: "Replayed" } }, + }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "synchronizing"); + } + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + expect(observed.filter((status, index) => observed[index - 1] !== status)).toEqual( + statuses, + ); + stop(); + remount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("downgrades a warm resume when the connection dropped while away", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + yield* SubscriptionRef.set(h.sessionRef, Option.none()); + yield* SubscriptionRef.set(h.connectionState, AVAILABLE_CONNECTION_STATE); + const remount = h.registry.mount(h.stateAtom); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "cached"); + expect(currentThread(h.registry, h.stateAtom)).toBe(THREAD); + expect(h.counts().opened).toBe(1); + + yield* SubscriptionRef.set(h.connectionState, CONNECTED_STATE); + yield* SubscriptionRef.set(h.sessionRef, Option.some(h.session)); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + remount(); + yield* Deferred.await(next.closed); + }), + ); + it.effect("keeps warm data when the raw atom family's weak entry is collected", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a0055b6cab3c..cdd07087d06a 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -158,10 +158,18 @@ function matchesThreadSnapshot( currentPage.hasMore === page.hasMore; } +// A retained "live" state stays live: the cursor resume that follows only +// replays what the thread missed, and on servers that send the completion +// marker the first replayed event moves the status to "synchronizing" on its +// own. Downgrading here would flash a sync label on every return to a +// recently viewed thread. function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, - status: value.status === "deleted" ? "deleted" : statusWithoutLiveData(value.data), + status: + value.status === "deleted" || (value.status === "live" && Option.isSome(value.data)) + ? value.status + : statusWithoutLiveData(value.data), error: Option.none(), page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })), }; @@ -647,7 +655,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), }); - yield* setSynchronizing; + // Only the first subscription after a warm live resume keeps the retained + // status. A replacement session or foreground resubscribe on the same scope + // may have missed events, so those show sync progress until confirmed. + const resumingLive = yield* Ref.make(initialState.status === "live"); + const markSynchronizing = Effect.gen(function* () { + if (yield* Ref.get(resumingLive)) return; + yield* setSynchronizing; + }); + + yield* markSynchronizing; yield* Effect.forkScoped( subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, @@ -668,7 +685,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const supportsPagination = config.threadSnapshotPagination === true; yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); - yield* setSynchronizing; + yield* markSynchronizing; + yield* Ref.set(resumingLive, false); let current = yield* SubscriptionRef.get(state); // A windowed cache resuming against a server without pagination is a From 371e32392570fdc0d2309e9f92490bbd2c6b3f02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:14 -0700 Subject: [PATCH 020/103] test(server): remove authorization prompt snapshots (#9985) --- apps/server/src/cli/connect.test.ts | 19 ------------------- apps/server/src/cli/connect.ts | 4 ++-- apps/server/src/cloud/CliTokenManager.test.ts | 13 ------------- apps/server/src/cloud/CliTokenManager.ts | 2 +- 4 files changed, 3 insertions(+), 35 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 1e0c88c24e84..f05eeab631f6 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -13,31 +13,12 @@ import * as Terminal from "effect/Terminal"; import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, - formatHeadlessAuthorizationPrompt, - formatRelayClientReady, headlessSessionConfig, isPublishAgentActivityEnabledValue, reportCloudDisconnectResults, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; -it("explains how to complete headless authorization", () => { - assert.equal( - formatHeadlessAuthorizationPrompt("https://example.test/connect"), - [ - "Headless authorization", - "Open this URL on a device with a browser:", - " https://example.test/connect", - "", - "After signing in, return here and enter the code shown in your browser.", - ].join("\n"), - ); -}); - -it("formats relay readiness without printing its installation path", () => { - assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); -}); - const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 25cfb18f3402..faa8f69871df 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -86,7 +86,7 @@ const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_ }, ); -export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { +function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { return [ "Headless authorization", "Open this URL on a device with a browser:", @@ -464,7 +464,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (identity ? ` as ${identity}` : ""); -export function formatRelayClientReady(version: string): string { +function formatRelayClientReady(version: string): string { return `✓ Relay client ready · cloudflared ${version}`; } diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index e6eb6b7cd6fd..33a0f1224961 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -93,19 +93,6 @@ class PromptRejectedError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -it("formats loopback authorization with a headless-host fallback", () => { - assert.equal( - CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), - [ - "Open this URL to authorize T3 Connect:", - " https://clerk.example.test/authorize", - "", - "Press \u001b[1mEnter\u001b[22m to open it in your browser.", - "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", - ].join("\n"), - ); -}); - const makeTestTerminal = (queue: Queue.Queue) => Terminal.make({ columns: Effect.succeed(80), diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index c4443a7301cb..8c3869accc76 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,7 +44,7 @@ const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; -export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { +function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { return [ "Open this URL to authorize T3 Connect:", ` ${authorizationUrl}`, From 10421bcdc9a059e0a717250257769af92567b645 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:18 -0700 Subject: [PATCH 021/103] test(server): remove static OAuth page snapshots (#9986) --- apps/server/src/cloud/cliAuthHtml.test.ts | 31 ----------------------- apps/server/src/cloud/cliAuthHtml.ts | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 apps/server/src/cloud/cliAuthHtml.test.ts diff --git a/apps/server/src/cloud/cliAuthHtml.test.ts b/apps/server/src/cloud/cliAuthHtml.test.ts deleted file mode 100644 index 1104927b9800..000000000000 --- a/apps/server/src/cloud/cliAuthHtml.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "@effect/vitest"; - -import { - renderLoopbackAuthorizationCompleteHtml, - resolveLoopbackAuthorizationStage, -} from "./cliAuthHtml.ts"; - -it("renders the branded loopback authorization completion page", () => { - const html = renderLoopbackAuthorizationCompleteHtml(); - - expect(resolveLoopbackAuthorizationStage()).toBe("dev"); - expect(html).toContain("T3 Code (Dev)"); - expect(html).toContain('class="stage stage-dev"'); - expect(html).not.toContain("Secure terminal handoff"); - expect(html).toContain("You're connected"); - expect(html).toContain("Return to your terminal"); - expect(html).not.toContain('class="next"'); - expect(html).toContain('name="viewport"'); - expect(html).not.toContain('class="status"'); -}); - -it("renders the matching header treatment for each release channel", () => { - const nightly = renderLoopbackAuthorizationCompleteHtml("nightly"); - const latest = renderLoopbackAuthorizationCompleteHtml("latest"); - - expect(nightly).toContain("T3 Code (Nightly)"); - expect(nightly).toContain('class="stage stage-nightly"'); - expect(latest).toContain('

T3 Code

'); - expect(latest).not.toContain("(Latest)"); - expect(latest).toContain('class="stage stage-latest"'); -}); diff --git a/apps/server/src/cloud/cliAuthHtml.ts b/apps/server/src/cloud/cliAuthHtml.ts index 5a22a25993a9..69d3b471ae32 100644 --- a/apps/server/src/cloud/cliAuthHtml.ts +++ b/apps/server/src/cloud/cliAuthHtml.ts @@ -2,7 +2,7 @@ export type LoopbackAuthorizationStage = "dev" | "nightly" | "latest"; declare const __T3CODE_BUILD_CHANNEL__: "nightly" | "latest" | undefined; -export function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { +function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { return typeof __T3CODE_BUILD_CHANNEL__ === "undefined" ? "dev" : __T3CODE_BUILD_CHANNEL__; } From 9867eb12396439f58e03e3548e053d966f0f64a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:22 -0700 Subject: [PATCH 022/103] test(server): remove provider label identity assertion (#9987) --- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 5 ----- .../src/orchestration/Layers/ProviderCommandReactor.ts | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4d35c5b04dd4..a8b26fe52cbd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -61,7 +61,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { - providerErrorLabel, providerErrorLabelFromInstanceHint, ProviderCommandReactorLive, } from "./ProviderCommandReactor.ts"; @@ -164,10 +163,6 @@ describe("ProviderCommandReactor", () => { }), ).toBe("claude_openrouter"); }); - - it("uses the unknown driver kind when the resolved driver is not registered locally", () => { - expect(providerErrorLabel("third_party_driver")).toBe("third_party_driver"); - }); }); async function createHarness(input?: { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b8e457e34ebb..fc963bcc9cb2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -228,7 +228,7 @@ function formatThreadTitleContext(messages: ReadonlyArray): }; } -export function providerErrorLabel(value: string | undefined): string { +function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : "unknown"; } From 94d1fa7ff268e88476a341eb73f9f032e2af6a21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:44 -0700 Subject: [PATCH 023/103] test(server): consolidate agent activity opt-in coverage (#9988) --- apps/server/src/cli/connect.test.ts | 8 -------- apps/server/src/cli/connect.ts | 6 +----- apps/server/src/relay/AgentAwarenessRelay.test.ts | 8 +++++--- apps/server/src/relay/AgentAwarenessRelay.ts | 6 +----- 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index f05eeab631f6..f3cc88d1b58a 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -14,7 +14,6 @@ import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, headlessSessionConfig, - isPublishAgentActivityEnabledValue, reportCloudDisconnectResults, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; @@ -190,10 +189,3 @@ it.effect("keeps disconnect causes in structured logs and out of console warning ), ); }); - -it("treats only the literal 'true' as publish-enabled", () => { - assert.equal(isPublishAgentActivityEnabledValue("true"), true); - assert.equal(isPublishAgentActivityEnabledValue("false"), false); - assert.equal(isPublishAgentActivityEnabledValue(null), false); - assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false); -}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index faa8f69871df..b7c78e5ea68b 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -144,10 +144,6 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -export function isPublishAgentActivityEnabledValue(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - interface CloudCliStatus { readonly desired: boolean; readonly authenticated: boolean; @@ -573,7 +569,7 @@ const connectStatusCommand = Command.make("status", { linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, - publishAgentActivity: isPublishAgentActivityEnabledValue( + publishAgentActivity: isAgentActivityPublishingEnabledValue( Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null, ), relayClient: executable, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 5c79a543fb46..ee23cbffaf0d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -39,6 +39,7 @@ import { type ProjectionSnapshotQueryShape, } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { + isAgentActivityPublishingEnabledValue, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, RELAY_URL_SECRET, @@ -220,9 +221,10 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }); it("requires an explicit opt-in before publishing agent activity", () => { - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled(null)).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("false")).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("true")).toBe(true); + expect(isAgentActivityPublishingEnabledValue(null)).toBe(false); + expect(isAgentActivityPublishingEnabledValue("false")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("TRUE")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("true")).toBe(true); }); it("redacts failed activity details and caps other relay detail", () => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5127ecf7d359..3dd0df642ce8 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -102,10 +102,6 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n return JSON.stringify(meaningfulState); } -export function isAgentActivityPublishingEnabled(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - export function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -322,7 +318,7 @@ export const make = Effect.gen(function* () { }); const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( - Effect.map(isAgentActivityPublishingEnabled), + Effect.map(isAgentActivityPublishingEnabledValue), ); const makeRelayClient = (relayConfig: { From aca2afc0b5400e4cf88c42d59b214240833ed9cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:47 -0700 Subject: [PATCH 024/103] refactor(shared): remove unused preview URL predicate (#9989) --- packages/shared/src/preview.test.ts | 19 ------------------- packages/shared/src/preview.ts | 11 ----------- 2 files changed, 30 deletions(-) diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index fec4203c5334..14139216194e 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { isLoopbackHost, - isPreviewableUrl, newPreviewTabId, normalizePreviewUrl, PreviewUrlNormalizationError, @@ -27,24 +26,6 @@ describe("isLoopbackHost", () => { }); }); -describe("isPreviewableUrl", () => { - it.each([ - "http://localhost:5173", - "http://127.0.0.1:3000/path", - "http://0.0.0.0:8080", - "http://[::1]:5173", - ])("%s is previewable", (url) => { - expect(isPreviewableUrl(url)).toBe(true); - }); - - it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])( - "%s is not previewable", - (url) => { - expect(isPreviewableUrl(url)).toBe(false); - }, - ); -}); - describe("normalizePreviewUrl", () => { it("treats bare loopback hosts as http", () => { expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 926b30966e52..f0a781290b1c 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -36,17 +36,6 @@ export function isLoopbackHost(host: string): boolean { return false; } -/** True when a raw URL string looks like a loopback dev URL we can preview. */ -export function isPreviewableUrl(rawUrl: string): boolean { - try { - const parsed = new URL(rawUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; - return isLoopbackHost(parsed.hostname); - } catch { - return false; - } -} - export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( "PreviewUrlNormalizationError", { From c6410d37d7ff1bdb65b38f0e9c30da392a4de804 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:51 -0700 Subject: [PATCH 025/103] refactor(shared): remove unused mention path serializer (#9990) --- packages/shared/src/composerTrigger.test.ts | 16 +--------------- packages/shared/src/composerTrigger.ts | 9 --------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 50c8cd7c2080..4b2763854457 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,20 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { serializeComposerFileLink, serializeComposerMentionPath } from "./composerTrigger.ts"; - -describe("serializeComposerMentionPath", () => { - it("keeps simple mention paths unquoted", () => { - expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts"); - }); - - it("quotes mention paths containing whitespace", () => { - expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"'); - }); - - it("escapes quoted mention path content", () => { - expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"'); - }); -}); +import { serializeComposerFileLink } from "./composerTrigger.ts"; describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index dcbdc784934b..c68a9963ffa8 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -8,15 +8,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/; - -export function serializeComposerMentionPath(path: string): string { - if (SIMPLE_MENTION_PATH_REGEX.test(path)) { - return path; - } - return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; -} - function composerFileLinkBasename(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; From da1bebbb11701b58ebdde5ee019f3aeef3d42b27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:54 -0700 Subject: [PATCH 026/103] refactor(shared): remove retired PATH capture parser (#9991) --- packages/shared/src/shell.test.ts | 23 ----------------------- packages/shared/src/shell.ts | 14 -------------- 2 files changed, 37 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index c98c1c452d4b..621fe49b3087 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -8,7 +8,6 @@ import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it, vi } from "vite-plus/test"; import { - extractPathFromShellOutput, CommandAvailability, CommandResolutionCache, type CommandAvailabilityChecker, @@ -39,28 +38,6 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); -describe("extractPathFromShellOutput", () => { - it("extracts the path between capture markers", () => { - expect( - extractPathFromShellOutput( - "__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("ignores shell startup noise around the capture markers", () => { - expect( - extractPathFromShellOutput( - "Welcome to fish\n__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\nBye\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("returns null when the markers are missing", () => { - expect(extractPathFromShellOutput("/opt/homebrew/bin /usr/bin")).toBeNull(); - }); -}); - describe("readPathFromLoginShell", () => { it("uses a shell-agnostic printenv PATH probe", () => { const execFile = vi.fn< diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 7d7a7d7b4f41..07ac73f8c6a7 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -12,8 +12,6 @@ import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; import * as Context from "effect/Context"; -const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; -const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; @@ -179,18 +177,6 @@ export function listLoginShellCandidates( return candidates; } -export function extractPathFromShellOutput(output: string): string | null { - const startIndex = output.indexOf(PATH_CAPTURE_START); - if (startIndex === -1) return null; - - const valueStartIndex = startIndex + PATH_CAPTURE_START.length; - const endIndex = output.indexOf(PATH_CAPTURE_END, valueStartIndex); - if (endIndex === -1) return null; - - const pathValue = output.slice(valueStartIndex, endIndex).trim(); - return pathValue.length > 0 ? pathValue : null; -} - export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, From 3bcde91f8215ec24eeffdd4abdee8fcb8a5e94f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:19:29 -0700 Subject: [PATCH 027/103] refactor(client-runtime): remove unused subagent selectors (#9992) --- .../src/state/subagentRuntime.test.ts | 62 ------------------- .../src/state/subagentRuntime.ts | 55 ---------------- 2 files changed, 117 deletions(-) diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index e7f6965123b9..d366d7f0d4ee 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -5,10 +5,6 @@ import { foldSubagentActivities, formatSubagentModelLabel, formatSubagentTokenCount, - isAgentAttributedToolActivity, - isSubagentActivityKind, - isTimelineBypassActivity, - workflowCardMembers, } from "./subagentRuntime.ts"; let sequence = 0; @@ -557,64 +553,6 @@ describe("deriveAgentPanelModel", () => { }); }); -describe("workflowCardMembers", () => { - it("orders by urgency (failed, running, waiting) and reports overflow", () => { - const roster = fold([ - activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), - ...[..."abcdefghij"].map((letter, index) => - activity("task.progress", { - taskId: `wf-1:wf:${index}`, - title: `agent-${letter}`, - status: index === 3 ? "failed" : index < 3 ? "completed" : "running", - ...(index === 3 ? { error: "died" } : {}), - parentAgentId: "wf-1", - agentIndex: index, - phaseIndex: 0, - phaseTitle: "Work", - }), - ), - ]); - const model = deriveAgentPanelModel({ agents: roster }); - const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8); - expect(visible).toHaveLength(8); - expect(overflow).toBe(2); - expect(visible[0]!.status).toBe("failed"); - expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2); - }); -}); - -describe("timeline predicates", () => { - it("recognizes subagent activity kinds as fold input", () => { - for (const kind of [ - "task.started", - "task.progress", - "task.updated", - "task.completed", - "tool.progress", - ]) { - expect(isSubagentActivityKind(kind)).toBe(true); - } - expect(isSubagentActivityKind("tool.completed")).toBe(false); - }); - - it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => { - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe( - true, - ); - expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false); - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe( - false, - ); - }); - - it("timelineBypass rows never render in the parent chat", () => { - expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe( - true, - ); - expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false); - }); -}); - describe("formatSubagentTokenCount", () => { it("formats plain counters", () => { expect(formatSubagentTokenCount(950)).toBe("950"); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 532cda20d050..e441de32db48 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -861,61 +861,6 @@ export function deriveAgentPanelModel({ }; } -/** - * Members ordered by urgency for the capped inline workflow card: running and - * failed first, then waiting, then most recently updated. - */ -export function workflowCardMembers( - group: AgentPanelWorkflowGroup, - limit: number, -): { readonly visible: ReadonlyArray; readonly overflow: number } { - const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; - const urgency = (agent: RuntimeSubagent): number => { - if (agent.status === "failed") return 0; - if (agent.status === "running") return 1; - if (agent.status === "waiting") return 2; - return 3; - }; - const ordered = all - .slice() - .sort((a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt)); - return { - visible: ordered.slice(0, limit), - overflow: Math.max(0, ordered.length - limit), - }; -} - -/** Kinds the timeline should not render as generic rows (fold input only). */ -export function isSubagentActivityKind(kind: string): boolean { - return ( - kind === "task.started" || - kind === "task.progress" || - kind === "task.updated" || - kind === "task.completed" || - kind === "tool.progress" - ); -} - -/** - * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in - * the Agents surface, not the parent chat. Unattributed rows must stay. - */ -export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - const payload = activity.payload as Record; - return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; -} - -/** Timeline-bypassing synthesized rows (Codex children, workflow members). */ -export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - return (activity.payload as Record).timelineBypass === true; -} - /** * Compact model chip text: strips vendor prefixes/date-or-context suffixes * ("claude-sonnet-5[1m]" → "sonnet-5[1m]", "claude-opus-4-20250514" → From 487d1766caba52579f07a3e5ebc6f276227e4382 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:19:33 -0700 Subject: [PATCH 028/103] refactor(web): test the live usage column builder (#9993) --- .../usage/UsageProviderChart.test.ts | 16 ++++++++------- .../components/usage/UsageProviderChart.tsx | 20 +------------------ 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index a4114cfdfb57..622d73d13844 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildDayColumns, niceScale } from "./UsageProviderChart"; +import { buildPeriodColumns, niceScale } from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; describe("niceScale", () => { @@ -41,7 +41,7 @@ describe("niceScale", () => { }); }); -describe("buildDayColumns", () => { +describe("buildPeriodColumns", () => { const days = ["2026-08-01", "2026-08-02", "2026-08-03"]; const byDay = new Map([ [ @@ -69,11 +69,13 @@ describe("buildDayColumns", () => { ]); it("plots each day on its own", () => { - expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([30, 0, 5]); + expect(buildPeriodColumns(days, byDay, "cost").map((column) => column.total)).toEqual([ + 30, 0, 5, + ]); }); it("reads the requested metric", () => { - expect(buildDayColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ + expect(buildPeriodColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ 300, 0, 50, ]); }); @@ -81,7 +83,7 @@ describe("buildDayColumns", () => { it("keeps band values absolute rather than cumulative", () => { // Regression: the bands were once stack offsets, which drew Claude Code // permanently above Codex regardless of which provider spent more. - const [first] = buildDayColumns(days, byDay, "cost"); + const [first] = buildPeriodColumns(days, byDay, "cost"); expect(first?.bands).toEqual([ { provider: "codex", value: 10 }, @@ -91,7 +93,7 @@ describe("buildDayColumns", () => { }); it("reports the total as the sum of its bands", () => { - for (const column of buildDayColumns(days, byDay, "cost")) { + for (const column of buildPeriodColumns(days, byDay, "cost")) { const sum = column.bands.reduce((running, band) => running + band.value, 0); expect(column.total).toBeCloseTo(sum, 9); } @@ -125,7 +127,7 @@ describe("hourly chart columns", () => { ]); expect( - buildDayColumns( + buildPeriodColumns( ["2026-08-11T08:37:00.000Z", "2026-08-11T09:37:00.000Z", "2026-08-11T10:37:00.000Z"], byHour, "cost", diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 26d49e664804..4a66349ddfa5 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -54,7 +54,7 @@ function valueFor( return metric === "tokens" ? entry.totalTokens : entry.costUsd; } -function buildPeriodColumns( +export function buildPeriodColumns( periods: readonly string[], byPeriod: ReadonlyMap, metric: UsageChartMetric, @@ -169,24 +169,6 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re return { max, ticks }; } -/** - * Turns the merged daily totals into one column per day. - * - * Values are absolute, not cumulative: each provider is drawn from the same - * zero baseline so the chart never implies that one provider is always larger. - * - * The chart paths and the hover readout both consume this, so the number under - * the cursor is by construction the number that was plotted rather than a - * second derivation that can drift from it. - */ -export function buildDayColumns( - days: readonly string[], - byDay: ReadonlyMap, - metric: UsageChartMetric, -): readonly DayColumn[] { - return buildPeriodColumns(days, byDay, metric); -} - export function UsageProviderChart({ providers, days, From c7e93f520bbd1ba4b795a5fb1aad14778aa1d047 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:19:37 -0700 Subject: [PATCH 029/103] refactor(web): remove unused aspect ratio reconciler (#9994) --- apps/web/src/browser/BrowserDeviceToolbar.test.ts | 13 +------------ apps/web/src/browser/browserDeviceToolbarState.ts | 7 ------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts index ee4987794c33..087b5d109b40 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.test.ts +++ b/apps/web/src/browser/BrowserDeviceToolbar.test.ts @@ -1,10 +1,7 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { - commitViewportAndAspectRatio, - reconcileLockedAspectRatio, -} from "./browserDeviceToolbarState"; +import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; describe("commitViewportAndAspectRatio", () => { it("commits the aspect ratio only after the viewport succeeds", async () => { @@ -39,11 +36,3 @@ describe("commitViewportAndAspectRatio", () => { expect(onAspectRatioChange).not.toHaveBeenCalled(); }); }); - -describe("reconcileLockedAspectRatio", () => { - it("tracks external viewport ratios only while the lock remains active", () => { - expect(reconcileLockedAspectRatio(1.5, 16 / 9)).toBe(16 / 9); - expect(reconcileLockedAspectRatio(null, 16 / 9)).toBeNull(); - expect(reconcileLockedAspectRatio(1.5, null)).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts index 9986ee022829..70a8e597428b 100644 --- a/apps/web/src/browser/browserDeviceToolbarState.ts +++ b/apps/web/src/browser/browserDeviceToolbarState.ts @@ -1,12 +1,5 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; -export function reconcileLockedAspectRatio( - current: number | null, - viewportAspectRatio: number | null, -): number | null { - return current === null || viewportAspectRatio === null ? null : viewportAspectRatio; -} - export async function commitViewportAndAspectRatio( setting: PreviewViewportSetting, aspectRatio: number | null, From bf1c1b09756a56a0600faf52561f0fb4ae94e355 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:19:41 -0700 Subject: [PATCH 030/103] refactor(web): remove obsolete cloud listing helpers (#9995) --- apps/web/src/cloud/linkEnvironment.test.ts | 45 ----------------- apps/web/src/cloud/linkEnvironment.ts | 56 ---------------------- 2 files changed, 101 deletions(-) diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 7ae5e7ed03a9..3ae0dbd74289 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -26,10 +26,7 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { - collectCloudLinkTargets, linkPrimaryEnvironmentToCloud, - listManagedCloudEnvironments, - normalizeRelayBaseUrl, readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, @@ -155,48 +152,6 @@ afterEach(() => { }); describe("web cloud link environment client", () => { - it("normalizes relay URLs and de-duplicates cloud link targets", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - expect( - collectCloudLinkTargets({ - primary: TARGET, - saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], - }).map((target) => target.environmentId), - ).toEqual(["environment-1", "environment-2"]); - }); - - it.effect("lists relay-managed environments through the typed relay client", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - environments: [ - { - environmentId: "environment-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-06T00:00:00.000Z", - }, - ], - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const environments = yield* withServices( - listManagedCloudEnvironments({ clerkToken: "clerk-token" }), - ); - - expect(environments).toHaveLength(1); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - }), - ); - it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 29353e480f50..f88e7863969d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -16,7 +16,6 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { - type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; @@ -33,14 +32,6 @@ import { requestRelayClientInstallConfirmation, } from "./relayClientInstallDialog"; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function relayUrl(): string | null { return resolveCloudPublicConfig().relayUrl; } @@ -194,53 +185,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export function collectCloudLinkTargets(input: { - readonly primary: CloudLinkTarget | null; - readonly saved: ReadonlyArray; -}): ReadonlyArray { - const byId = new Map(); - if (input.primary) { - byId.set(input.primary.environmentId, input.primary); - } - for (const environment of input.saved) { - if (!byId.has(environment.environmentId)) { - byId.set(environment.environmentId, environment); - } - } - return [...byId.values()]; -} - -export function listManagedCloudEnvironments(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list relay-managed environments.", - cause, - }), - ), - ); - }); -} - export function readPrimaryCloudLinkState(input: { readonly target: CloudLinkTarget; }): Effect.Effect { From 4f1dc55cabe6cb4a89c2180d6b0e270584bb8883 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:20:07 -0700 Subject: [PATCH 031/103] test(web): remove composer control style snapshots (#9996) --- .../components/chat/ComposerControl.test.tsx | 76 ------------------- 1 file changed, 76 deletions(-) delete mode 100644 apps/web/src/components/chat/ComposerControl.test.tsx diff --git a/apps/web/src/components/chat/ComposerControl.test.tsx b/apps/web/src/components/chat/ComposerControl.test.tsx deleted file mode 100644 index 397578179b9f..000000000000 --- a/apps/web/src/components/chat/ComposerControl.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { BotIcon } from "lucide-react"; -import { describe, expect, it } from "vite-plus/test"; - -import { - ComposerControl, - ComposerControlChevron, - ComposerControlIcon, - ComposerControlSeparator, -} from "./ComposerControl"; - -describe("ComposerControl", () => { - it("preserves the expanded composer geometry by default", () => { - const markup = renderToStaticMarkup(Model); - - expect(markup).toContain("h-7"); - expect(markup).toContain("min-h-7"); - expect(markup).toContain("gap-1.5"); - expect(markup).toContain("px-2.5"); - }); - - it("uses the shared xs geometry for resting controls", () => { - const markup = renderToStaticMarkup( - - Model - - , - ); - - expect(markup).toContain("sm:h-6"); - expect(markup).toContain("font-normal"); - expect(markup).toContain("text-muted-foreground/70"); - expect(markup).toContain("[--control-icon-color:currentColor]"); - expect(markup).toContain("svg[data-composer-control-chevron]]:ms-0"); - expect(markup).toContain("svg[data-composer-control-chevron]]:-me-1"); - expect(markup).not.toContain("min-h-7"); - expect(markup).not.toContain("gap-1.5"); - expect(markup).not.toContain("px-2.5"); - }); - - it("keeps the expanded chevron treatment unless resting overrides it", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup(); - - expect(expanded).toContain("size-3.5"); - expect(expanded).toContain("text-icon-muted"); - expect(expanded).toContain('stroke-width="2.25"'); - expect(resting).toContain("size-3"); - expect(resting).toContain("text-current"); - expect(resting).toContain("opacity-50"); - expect(resting).not.toContain("size-3.5"); - expect(resting).not.toContain("text-icon-muted"); - }); - - it("owns resting icon geometry", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup(); - - expect(expanded).toContain("size-4"); - expect(resting).toContain("size-3"); - expect(resting).not.toContain("size-4"); - }); - - it("owns separator geometry for both composer sizes", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup( - , - ); - - expect(expanded).toContain("h-4"); - expect(expanded).not.toContain("h-3.5!"); - expect(resting).toContain("h-3.5!"); - expect(resting).not.toContain("h-4"); - expect(resting).toContain('data-resting-controls-separator="true"'); - }); -}); From 11cb88efc54c5690e25348ce5c889722a3615089 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:20:11 -0700 Subject: [PATCH 032/103] test(web): keep the preview profile label helper private (#9997) --- apps/web/src/components/preview/PreviewView.test.tsx | 8 +------- apps/web/src/components/preview/PreviewView.tsx | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index bb20ee362376..9456daef72d8 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -251,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); -import { PreviewView, previewProfileName } from "./PreviewView"; +import { PreviewView } from "./PreviewView"; import { toastManager } from "~/components/ui/toast"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -352,12 +352,6 @@ describe("PreviewView navigation", () => { mocks.recordVisitForThread.mockClear(); }); - it("labels a tab whose saved profile was removed", () => { - expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe( - "Removed profile", - ); - }); - it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 640690854e53..7e0bf2dfb543 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -76,7 +76,7 @@ interface Props { ) => void; } -export function previewProfileName( +function previewProfileName( profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>, profileId: string, ): string { From 3fedf52467dd8d2734a7aca4c6c7da421c12474f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:22:14 -0700 Subject: [PATCH 033/103] test(server): cover raw OpenCode deltas through the adapter (#9977) --- .../src/provider/Layers/OpenCodeAdapter.test.ts | 14 +++++--------- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index fb0e9aa9ef2d..81e799c9095e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -43,7 +43,6 @@ import { type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; import { - appendOpenCodeAssistantTextDelta, isOpenCodeNotFound, isSameOpenCodeDirectory, makeOpenCodeAdapter, @@ -6430,20 +6429,17 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.scoped), ); - it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => + it.effect("reconciles assistant text snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); - const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); - const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); - NodeAssert.deepEqual( - [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], - ["Hello", "lo world", ""], - ); - NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(firstUpdate, { + latestText: "Hello", + deltaToEmit: "Hello", + }); NodeAssert.deepEqual(appendedUpdate, { latestText: "Hello world", deltaToEmit: " world", diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index ea777d1e15f9..b8aa7d4a9a52 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -626,7 +626,7 @@ export function mergeOpenCodeAssistantText( }; } -export function appendOpenCodeAssistantTextDelta( +function appendOpenCodeAssistantTextDelta( previousText: string, delta: string, ): { From ba873b8181c3741603a365e75785457064a52ffd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:22:17 -0700 Subject: [PATCH 034/103] refactor(web): remove obsolete pull request link opener (#9983) --- apps/web/src/lib/openPullRequestLink.test.ts | 31 +--------------- apps/web/src/lib/openPullRequestLink.ts | 37 -------------------- 2 files changed, 1 insertion(+), 67 deletions(-) diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 6029c9bb3dab..ad4971d331d8 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -1,14 +1,12 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { changeRequestRepositoryUrl, findProjectForChangeRequest, gitHubPullRequestBrowserUrl, matchesLinkedPullRequestUrl, - openPullRequestLink, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, - PullRequestLinkOpenError, shouldOpenPullRequestExternally, } from "./openPullRequestLink"; import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; @@ -184,33 +182,6 @@ describe("matchesLinkedPullRequestUrl", () => { }); }); -describe("openPullRequestLink", () => { - it("opens the requested pull request URL", async () => { - const openExternal = vi.fn(async () => undefined); - const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; - - await openPullRequestLink({ openExternal }, targetUrl); - - expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); - }); - - it("reports bridge failures with a safe target origin", async () => { - const cause = new Error("desktop shell unavailable"); - const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; - const openExternal = vi.fn(async () => Promise.reject(cause)); - - const result = openPullRequestLink({ openExternal }, targetUrl); - - await expect(result).rejects.toEqual( - new PullRequestLinkOpenError({ - targetOrigin: "https://github.com", - cause, - }), - ); - await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); - }); -}); - describe("shouldOpenPullRequestExternally", () => { it("uses the browser for command-click and control-click", () => { expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false })).toBe(true); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0050c62bc727..2d46e3984fd9 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,12 +1,10 @@ import type { EnvironmentId, - LocalApi, RepositoryIdentity, ScopedThreadRef, ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; -import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; @@ -19,41 +17,6 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; -export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( - "PullRequestLinkOpenError", - { - targetOrigin: Schema.NullOr(Schema.String), - cause: Schema.Defect(), - }, -) { - static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { - let targetOrigin: string | null = null; - try { - targetOrigin = new URL(targetUrl).origin; - } catch { - // Keep malformed URLs out of diagnostics while preserving the open failure below. - } - return new PullRequestLinkOpenError({ targetOrigin, cause }); - } - - override get message(): string { - return this.targetOrigin === null - ? "Unable to open pull request link." - : `Unable to open pull request link at ${this.targetOrigin}.`; - } -} - -export async function openPullRequestLink( - shell: Pick, - targetUrl: string, -): Promise { - try { - await shell.openExternal(targetUrl); - } catch (cause) { - throw PullRequestLinkOpenError.fromCause(targetUrl, cause); - } -} - /** Builds a GitHub URL that remains available when the pull request API cannot be read. */ export function gitHubPullRequestBrowserUrl( identity: RepositoryIdentity | null | undefined, From ac93fbfad01ecd99f818d9964463afb6c35a8f4a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:25:45 -0700 Subject: [PATCH 035/103] test(relay): keep the stage slug helper private (#9998) --- infra/relay/src/deploymentConfig.test.ts | 7 ------- infra/relay/src/deploymentConfig.ts | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index 44c7627a4daf..f090c70ee22b 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -11,17 +11,10 @@ import { RelayPublicDomainLabelTooLongError, relayPublicDomainForStage, relayResourceNameForStage, - relayStageSlug, } from "./deploymentConfig.ts"; const isRelayPublicDomainLabelTooLongError = Schema.is(RelayPublicDomainLabelTooLongError); -describe("relayStageSlug", () => { - it("matches Alchemy physical-name sanitization for default developer stages", () => { - expect(relayStageSlug("dev_julius")).toBe("dev-julius"); - }); -}); - describe("relayPublicDomainForStage", () => { it("uses the canonical relay hostname for production", () => { expect(relayPublicDomainForStage("prod", ".example.com.")).toBe("relay.example.com"); diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index fe9d37b29988..565e16422dd2 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -56,7 +56,7 @@ function appendDnsSafeSuffix(prefix: string, suffix: string): string { * Alchemy's physical-name helper sanitizes resource names after adding the * stage. Keep custom domains and runtime-created resources aligned with it. */ -export function relayStageSlug(stage: string): string { +function relayStageSlug(stage: string): string { return stage .toLowerCase() .replaceAll(/[^a-z0-9-]/g, "-") From 1e24b43d3f6eea6333a3c8da6946eb614d00a662 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:26:23 -0700 Subject: [PATCH 036/103] refactor(mobile): keep project selection helper private (#9999) --- .../threads/new-task-project-selection.test.ts | 13 ------------- .../features/threads/new-task-project-selection.ts | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 7068a95d558a..2d52ed716d50 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { - getOnlySelectableProject, getProjectScopeSelectionTarget, resolveDraftProjectSelection, } from "./new-task-project-selection"; @@ -36,18 +35,6 @@ function makeScope(projects: ReadonlyArray): HomeProjectScop }; } -describe("getOnlySelectableProject", () => { - it("auto-selects when there is exactly one physical project", () => { - const project = makeProject("t3code"); - expect(getOnlySelectableProject([makeScope([project])])).toBe(project); - }); - - it("selects the representative when one logical project has multiple workspaces", () => { - const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; - expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]); - }); -}); - describe("getProjectScopeSelectionTarget", () => { it("keeps the current environment when it hosts the selected logical project", () => { const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 7be899d62a1a..0528dc66687a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -19,7 +19,7 @@ export function getProjectScopeSelectionTarget( ); } -export function getOnlySelectableProject( +function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; From 1c59d3b72cbddefa186ce18bea5cd7e6e0ae4874 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:26:27 -0700 Subject: [PATCH 037/103] refactor(mobile): keep review default ID helper private (#10000) --- apps/mobile/src/features/review/reviewFileVisibility.test.ts | 2 -- apps/mobile/src/features/review/reviewFileVisibility.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/mobile/src/features/review/reviewFileVisibility.test.ts b/apps/mobile/src/features/review/reviewFileVisibility.test.ts index 4a7a2f98af62..8fec1cbf8bd5 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.test.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - getDefaultReviewExpandedFileIds, getValidExplicitReviewFileIds, getValidReviewFileIds, removeReviewFileId, @@ -29,7 +28,6 @@ describe("review file visibility", () => { const files = [makeFile("a.ts"), makeFile("b.ts")]; it("defaults expanded files to every renderable file", () => { - expect(getDefaultReviewExpandedFileIds(files)).toEqual(["a.ts", "b.ts"]); expect(getValidReviewFileIds(files, undefined)).toEqual(["a.ts", "b.ts"]); }); diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index 53f2d7f5f956..fbdfcf230225 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo } from "react"; import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState"; import type { ReviewRenderableFile } from "./reviewModel"; -export function getDefaultReviewExpandedFileIds( +function getDefaultReviewExpandedFileIds( files: ReadonlyArray, ): ReadonlyArray { return files.map((file) => file.id); From 3382b26c4f29bcbc709edf135c5486b7f1e0e211 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:26:31 -0700 Subject: [PATCH 038/103] refactor(mobile): remove unused native style constants (#10001) --- .../files/nativeSourceFileAdapter.test.ts | 20 ------------------- .../features/files/nativeSourceFileAdapter.ts | 6 ------ .../review/nativeReviewDiffAdapter.ts | 7 ------- 3 files changed, 33 deletions(-) diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts index 0e7d478c6bdb..937d3a1d3c8f 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -3,30 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import { buildNativeSourceRows, buildNativeSourceTokens, - NATIVE_SOURCE_ROW_HEIGHT, - NATIVE_SOURCE_STYLE, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { - NATIVE_REVIEW_DIFF_ROW_HEIGHT, - NATIVE_REVIEW_DIFF_STYLE, -} from "../review/nativeReviewDiffAdapter"; describe("nativeSourceFileAdapter", () => { - it("uses the same compact code typography as the diff viewer", () => { - expect(NATIVE_SOURCE_ROW_HEIGHT).toBe(NATIVE_REVIEW_DIFF_ROW_HEIGHT); - expect(NATIVE_SOURCE_STYLE).toMatchObject({ - rowHeight: NATIVE_REVIEW_DIFF_STYLE.rowHeight, - gutterWidth: NATIVE_REVIEW_DIFF_STYLE.gutterWidth, - codePadding: NATIVE_REVIEW_DIFF_STYLE.codePadding, - textVerticalInset: NATIVE_REVIEW_DIFF_STYLE.textVerticalInset, - codeFontSize: NATIVE_REVIEW_DIFF_STYLE.codeFontSize, - codeFontWeight: NATIVE_REVIEW_DIFF_STYLE.codeFontWeight, - lineNumberFontSize: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontSize, - lineNumberFontWeight: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontWeight, - }); - }); - it("maps plain source lines onto context rows with stable line numbers", () => { expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([ { diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts index 0c83134ea703..f1dbefdc383b 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -4,17 +4,11 @@ import type { NativeReviewDiffToken, } from "../diffs/nativeReviewDiffSurface"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; import type { SourceHighlightTokens } from "./sourceHighlightingState"; -export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; -export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - export function createNativeSourceStyle( codeSurface: ResolvedMobileCodeSurface, ): NativeReviewDiffStyle { diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 3c2eb9016feb..39b9c0cef26e 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -6,8 +6,6 @@ import type { import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; -import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme"; import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; @@ -25,13 +23,8 @@ const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; const NATIVE_RGBA_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; -export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; -export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - function opaqueNativeHexColor(color: string, background: string): string { const hex = NATIVE_HEX_COLOR.exec(color); if (hex) return color; From 393d1ffc9ee52696e4b5f226a956e9d4c65b2f6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:26:34 -0700 Subject: [PATCH 039/103] refactor(mobile): test terminal palettes through public theme API (#10002) --- .../features/terminal/terminalTheme.test.ts | 27 +++++-------------- .../src/features/terminal/terminalTheme.ts | 2 +- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/terminal/terminalTheme.test.ts b/apps/mobile/src/features/terminal/terminalTheme.test.ts index 24edb384bebb..478ddc96d960 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.test.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.test.ts @@ -3,15 +3,11 @@ import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/th import { themeColorToNativeColor } from "../../lib/mobileTheme"; -import { - buildGhosttyThemeConfig, - getMobileTerminalTheme, - getPierreTerminalTheme, -} from "./terminalTheme"; +import { buildGhosttyThemeConfig, getMobileTerminalTheme } from "./terminalTheme"; -describe("getPierreTerminalTheme", () => { - it("returns the Pierre light terminal palette", () => { - expect(getPierreTerminalTheme("light")).toMatchObject({ +describe("getMobileTerminalTheme", () => { + it("preserves the default light terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "light")).toMatchObject({ background: "#f2f2f7", foreground: "#6C6C71", cursorForeground: "#009fff", @@ -19,23 +15,14 @@ describe("getPierreTerminalTheme", () => { }); }); - it("returns the Pierre dark terminal palette", () => { - expect(getPierreTerminalTheme("dark")).toMatchObject({ + it("preserves the default dark terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "dark")).toMatchObject({ background: "#0a0a0a", foreground: "#adadb1", cursorForeground: "#009fff", cursorBackground: "#0a0a0a", }); }); -}); - -describe("getMobileTerminalTheme", () => { - it("preserves the Pierre terminal for the default theme", () => { - for (const scheme of ["light", "dark"] as const) { - expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme)); - } - }); - it("applies the selected palette without replacing ANSI status colors", () => { const standard = getMobileTerminalTheme("t3-code", "dark"); const ocean = getMobileTerminalTheme("ocean", "dark"); @@ -58,7 +45,7 @@ describe("getMobileTerminalTheme", () => { describe("buildGhosttyThemeConfig", () => { it("serializes theme colors into a ghostty config file", () => { - const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); + const config = buildGhosttyThemeConfig(getMobileTerminalTheme("t3-code", "dark")); expect(config).toContain("background = #0a0a0a"); expect(config).toContain("foreground = #adadb1"); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index 9a913022571d..569b10f7bd55 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -74,7 +74,7 @@ const PIERRE_DARK_THEME: TerminalTheme = { ], }; -export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { +function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; } From 8d48a3134f4d789f54128ccbb65919812ffc7921 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:26:51 -0700 Subject: [PATCH 040/103] refactor(mobile): remove unused file tree walkers (#10003) --- .../src/features/files/fileTree.test.ts | 10 +------- apps/mobile/src/features/files/fileTree.ts | 25 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index 7345a7f366c5..edab2ba687b8 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ProjectEntry } from "@t3tools/contracts"; -import { - buildFileTree, - countFileNodes, - defaultExpandedTreePaths, - firstFilePath, - flattenFileTree, -} from "./fileTree"; +import { buildFileTree, defaultExpandedTreePaths, flattenFileTree } from "./fileTree"; const entries = [ { kind: "file", path: "README.md" }, @@ -30,8 +24,6 @@ describe("mobile file tree helpers", () => { "directory:src/components", "file:src/index.ts", ]); - expect(countFileNodes(tree)).toBe(4); - expect(firstFilePath(tree)).toBe("src/components/App.tsx"); }); it("flattens expanded directories and hides collapsed descendants", () => { diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 28b5822aaa0f..2e0b8140329c 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -117,18 +117,6 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr return [...root.children.values()].sort(compareNodes).map(freezeNode); } -export function countFileNodes(nodes: ReadonlyArray): number { - let count = 0; - for (const node of nodes) { - if (node.kind === "file") { - count += 1; - } else { - count += countFileNodes(node.children); - } - } - return count; -} - export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { const expanded = new Set(); for (const node of nodes) { @@ -205,16 +193,3 @@ export function flattenFileTree(input: { } return output; } - -export function firstFilePath(nodes: ReadonlyArray): string | null { - for (const node of nodes) { - if (node.kind === "file") { - return node.path; - } - const child = firstFilePath(node.children); - if (child !== null) { - return child; - } - } - return null; -} From 1584076d7651278863f7ee90f5bf70c2aba981b3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:26:54 -0700 Subject: [PATCH 041/103] refactor(shared): keep persisted settings helpers private (#10004) --- packages/shared/src/serverSettings.test.ts | 35 ++++++++-------------- packages/shared/src/serverSettings.ts | 4 +-- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 1f847412b6c7..31f056c211e9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -11,43 +11,34 @@ import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySet import { createModelSelection } from "./model.ts"; import { applyServerSettingsPatch, - extractPersistedServerObservabilitySettings, isModelSelectionProviderEnabled, - normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, } from "./serverSettings.ts"; describe("serverSettings helpers", () => { - it("normalizes optional persisted strings", () => { - expect(normalizePersistedServerSettingString(undefined)).toBeUndefined(); - expect(normalizePersistedServerSettingString(" ")).toBeUndefined(); - expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe( - "http://localhost:4318/v1/traces", - ); - }); - - it("extracts persisted observability settings", () => { + it("ignores missing and blank persisted observability URLs", () => { + expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + }); expect( - extractPersistedServerObservabilitySettings({ - observability: { - otlpTracesUrl: " http://localhost:4318/v1/traces ", - otlpMetricsUrl: " http://localhost:4318/v1/metrics ", - }, - }), + parsePersistedServerObservabilitySettings( + JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }), + ), ).toEqual({ - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, }); }); - it("parses lenient persisted settings JSON", () => { + it("parses lenient persisted settings JSON and trims observability URLs", () => { expect( parsePersistedServerObservabilitySettings( JSON.stringify({ observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }), ), diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index dfd5b742e4e4..dc50da2d7627 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -69,14 +69,14 @@ export interface PersistedServerObservabilitySettings { readonly otlpMetricsUrl: string | undefined; } -export function normalizePersistedServerSettingString( +function normalizePersistedServerSettingString( value: string | null | undefined, ): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } -export function extractPersistedServerObservabilitySettings(input: { +function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; From 4e5e17fd9e2454a7b828a19f0b46e48cde0c7a90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:26:57 -0700 Subject: [PATCH 042/103] test(mobile): remove mocked UUID shape assertions (#10006) --- apps/mobile/src/lib/commandMetadata.test.ts | 36 --------------------- 1 file changed, 36 deletions(-) delete mode 100644 apps/mobile/src/lib/commandMetadata.test.ts diff --git a/apps/mobile/src/lib/commandMetadata.test.ts b/apps/mobile/src/lib/commandMetadata.test.ts deleted file mode 100644 index d1ba1d86eba9..000000000000 --- a/apps/mobile/src/lib/commandMetadata.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { makeQueuedMessageMetadata, makeTurnCommandMetadata } from "./commandMetadata"; - -vi.mock("expo-crypto", () => ({ - randomUUID: () => crypto.randomUUID(), -})); - -describe("mobile command metadata", () => { - it("creates ids and timestamps for thread starts", () => { - const metadata = makeTurnCommandMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.threadId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it("creates ids and timestamps for queued messages", () => { - const metadata = makeQueuedMessageMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); -}); From 1449deca0afb98a422ea54b0d0fbed56da9f9f8f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:27:13 -0700 Subject: [PATCH 043/103] refactor(mobile): test final connection status presentation (#10007) --- .../home/workspace-connection-status.test.ts | 66 ++++++------------- .../home/workspace-connection-status.ts | 4 +- 2 files changed, 22 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/home/workspace-connection-status.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts index 15a990bb1cbe..f1af93316a6b 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { WorkspaceState } from "../../state/workspaceModel"; -import { - shouldShowWorkspaceConnectionStatus, - workspaceConnectionStatusLabel, - workspaceConnectionStatusPresentation, -} from "./workspace-connection-status"; +import { workspaceConnectionStatusPresentation } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { return { @@ -27,14 +23,16 @@ function workspaceState(overrides: Partial = {}): WorkspaceState describe("workspace connection status", () => { it("stays hidden while a ready environment is connected", () => { - expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false); + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); }); it("surfaces offline snapshots", () => { const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("You are offline"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "You are offline", + showsProgress: false, + }); }); it("names the environment while reconnecting", () => { @@ -54,8 +52,10 @@ describe("workspace connection status", () => { ], }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); }); it("surfaces connection errors before the generic disconnected fallback", () => { @@ -65,15 +65,19 @@ describe("workspace connection status", () => { hasReadyEnvironment: false, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Could not reach Julius’s Mac mini", + showsProgress: false, + }); }); it("shows shell catch-up while cached threads remain visible", () => { const state = workspaceState({ hasPendingShellSnapshot: true }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads..."); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Syncing threads...", + showsProgress: true, + }); }); it("distinguishes initial shell loading from cached catch-up", () => { @@ -82,39 +86,9 @@ describe("workspace connection status", () => { hasPendingShellSnapshot: true, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); - }); - - it("presents nothing while connected", () => { - expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); - }); - - it("presents progress while reconnecting but not while offline", () => { - const reconnecting = workspaceState({ - hasConnectingEnvironment: true, - hasReadyEnvironment: false, - connectingEnvironments: [ - { - environmentId: "environment-1" as never, - environmentLabel: "Julius’s Mac mini", - displayUrl: "", - isRelayManaged: false, - connectionState: "reconnecting", - connectionError: null, - connectionErrorTraceId: null, - }, - ], - }); - expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ - label: "Reconnecting to Julius’s Mac mini", + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Loading threads...", showsProgress: true, }); - - const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(workspaceConnectionStatusPresentation(offline)).toEqual({ - label: "You are offline", - showsProgress: false, - }); }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 6f9898b1bb01..d45a46adf933 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -6,7 +6,7 @@ export interface WorkspaceConnectionStatusPresentation { readonly showsProgress: boolean; } -export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { +function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || state.connectionError !== null || @@ -16,7 +16,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool ); } -export function workspaceConnectionStatusLabel(state: WorkspaceState): string { +function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; if (state.connectingEnvironments.length === 1) { return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; From 4e59b06b84fdfa36f78b4fc9079dff88b4ff3dc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:27:48 -0700 Subject: [PATCH 044/103] test(web): keep pull request menu items private (#10016) --- .../pullRequest/pullRequestLinkContextMenu.test.ts | 9 +-------- .../components/pullRequest/pullRequestLinkContextMenu.ts | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts index db105f97fe95..eb6f47b4c803 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts @@ -1,15 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { openOnHostLabel, pullRequestLinkContextMenuItems } from "./pullRequestLinkContextMenu"; +import { openOnHostLabel } from "./pullRequestLinkContextMenu"; describe("pull request link context menu", () => { - it("offers the copy first and the host's own page after it", () => { - expect(pullRequestLinkContextMenuItems("Open on GitHub")).toEqual([ - { id: "copy-link", label: "Copy link", icon: "copy" }, - { id: "open-external", label: "Open on GitHub" }, - ]); - }); - it("names every host it knows, and says nothing false about one it does not", () => { expect(openOnHostLabel("github")).toBe("Open on GitHub"); expect(openOnHostLabel("gitlab")).toBe("Open on GitLab"); diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16b749445d4c..ef554d0eb3ee 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -19,7 +19,7 @@ export const openOnHostLabel = (provider: string): string => OPEN_ON_HOST_LABELS[provider] ?? "Open on host"; /** Copy first: it is the reason to right-click a number rather than click it. */ -export function pullRequestLinkContextMenuItems( +function pullRequestLinkContextMenuItems( openLabel: string, ): readonly ContextMenuItem[] { return [ From 3e544f8cda371dc565c806508a9ab38b80c96ff7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:28:48 -0700 Subject: [PATCH 045/103] refactor(shared): test favicon selection through public API (#10005) --- packages/shared/src/favicon.test.ts | 27 ++++++++++++++++----------- packages/shared/src/favicon.ts | 4 ++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index ce80a079b3fd..676f7811011e 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { - explicitFaviconUrl, - faviconUrlForOrigin, - faviconUrlForPage, - toolActivityFaviconUrl, -} from "./favicon.ts"; +import { faviconUrlForOrigin, toolActivityFaviconUrl } from "./favicon.ts"; describe("faviconUrlForOrigin", () => { it.each([ @@ -46,12 +41,12 @@ describe("faviconUrlForOrigin", () => { ); }); -describe("faviconUrlForPage", () => { +describe("toolActivityFaviconUrl", () => { it("uses the page origin instead of a third-party favicon service", () => { - expect(faviconUrlForPage("https://example.com/docs/page?q=1")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "https://example.com/docs/page?q=1" }, "light")).toBe( "https://example.com/favicon.ico", ); - expect(faviconUrlForPage("http://localhost:5173/app")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "http://localhost:5173/app" }, "light")).toBe( "http://localhost:5173/favicon.ico", ); }); @@ -86,7 +81,17 @@ describe("faviconUrlForPage", () => { }); it("accepts provider-supplied image URLs but rejects extension URLs", () => { - expect(explicitFaviconUrl("https://example.com/icon.png")).toBe("https://example.com/icon.png"); - expect(explicitFaviconUrl("chrome-extension://example/_favicon/")).toBeNull(); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "https://example.com/icon.png" }, + "light", + ), + ).toBe("https://example.com/icon.png"); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "chrome-extension://example/_favicon/" }, + "light", + ), + ).toBe("https://example.com/favicon.ico"); }); }); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index a3286b28e99a..2c4847115b90 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -5,7 +5,7 @@ import { isPublicFaviconHost } from "./hostClassification.ts"; * conventional favicon and let the image element fall back to a browser glyph. * Chrome-backed tools can pass their tab's explicit favicon URL separately. */ -export function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { +function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const pageUrl = new URL(rawUrl); @@ -40,7 +40,7 @@ function themedFaviconUrlForPage( } /** Accepts image URLs supplied by a trusted provider event. */ -export function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { +function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const url = new URL(rawUrl); From 270e021f2a7ed34e3d81d1cdc9f1ca0c0ac85df4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:28:52 -0700 Subject: [PATCH 046/103] refactor(server): remove test-only pricing normalizer (#10017) --- apps/server/src/usage/usagePricing.test.ts | 5 ----- apps/server/src/usage/usagePricing.ts | 10 ---------- 2 files changed, 15 deletions(-) diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index d45dfe2dd09b..713d860999cb 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -4,7 +4,6 @@ import { cacheSavingsUsd, createOverrideRateTable, lookupRate, - normalizeModelName, parseRateTable, priceUsage, } from "./usagePricing.ts"; @@ -83,10 +82,6 @@ describe("usage pricing", () => { } }); - it("keeps the existing model-name normalization contract", () => { - expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); - }); - it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 5ca75a68cb32..6c94be424827 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -127,16 +127,6 @@ function normalizeRateKey(model: string): string { return model.trim().toLowerCase(); } -/** - * Canonicalises a model name for lookup. - * - * Strips a `provider/` prefix and lowercases, since transcripts are - * inconsistent about casing. - */ -export function normalizeModelName(model: string): string { - return bareModelName(normalizeRateKey(model)); -} - function bareModelName(key: string): string { const slash = key.lastIndexOf("/"); return slash === -1 ? key : key.slice(slash + 1); From 93d4dfa2064dc4a598a3e66543c3b90ffb138609 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:29:32 -0700 Subject: [PATCH 047/103] refactor(web): remove unused desktop update visibility helper (#10014) --- apps/web/src/components/desktopUpdate.logic.test.ts | 9 +-------- apps/web/src/components/desktopUpdate.logic.ts | 10 ---------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index dd05693d28b2..fcc97825b681 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -12,7 +12,6 @@ import { isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; @@ -42,7 +41,6 @@ describe("desktop update button state", () => { status: "available", availableVersion: "1.1.0", }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); }); @@ -55,7 +53,6 @@ describe("desktop update button state", () => { errorContext: "download", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -70,7 +67,6 @@ describe("desktop update button state", () => { errorContext: "install", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -85,7 +81,6 @@ describe("desktop update button state", () => { errorContext: null, canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to restart and install"); }); @@ -111,7 +106,7 @@ describe("desktop update button state", () => { expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); - it("hides the button for non-actionable check errors", () => { + it("has no action for non-actionable check errors", () => { const state: DesktopUpdateState = { ...baseState, status: "error", @@ -119,7 +114,6 @@ describe("desktop update button state", () => { errorContext: "check", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(false); expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); @@ -130,7 +124,6 @@ describe("desktop update button state", () => { availableVersion: "1.1.0", downloadPercent: 42.5, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(isDesktopUpdateButtonDisabled(state)).toBe(true); expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 656ffbea8198..4a169cb3ef40 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -47,16 +47,6 @@ export function resolveDesktopUpdateButtonAction( return "none"; } -export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { - if (!state || !state.enabled) { - return false; - } - if (state.status === "downloading") { - return true; - } - return resolveDesktopUpdateButtonAction(state) !== "none"; -} - export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { return state?.hostArch === "arm64" && state.appArch === "x64"; } From c9b76e6f5d382fb83caafe419f75ead87a3dbabb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:29:36 -0700 Subject: [PATCH 048/103] refactor(web): remove obsolete provider update helpers (#10015) --- ...iderUpdateLaunchNotification.logic.test.ts | 91 ------------------- .../ProviderUpdateLaunchNotification.logic.ts | 71 --------------- 2 files changed, 162 deletions(-) diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 2ee06a6b6620..12f63f3e16fd 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -18,17 +18,14 @@ import { environmentGroupsWithUpdates, firstFailedProviderUpdateMessage, firstRejectedProviderUpdateMessage, - firstUnsuccessfulSecondaryProviderOutcome, getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, getProviderUpdateSidebarPillView, - getSingleProviderUpdateProgressToastView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, isTerminalProviderUpdatePhase, localEnvironmentUpdateNotificationKey, - parseWslDistroFromInstanceId, providerUpdateNotificationKey, resolveEnvironmentUpdateRowStatus, shouldShowPrimaryProviderUpdateToast, @@ -368,28 +365,6 @@ describe("provider update launch notification logic", () => { }); }); - it("resolves a single-provider completion view from the returned provider snapshot", () => { - const view = getSingleProviderUpdateProgressToastView( - provider({ - driver: driver("codex"), - updateState: { - status: "failed", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "command failed", - output: "stderr", - }, - }), - ); - - expect(view).toMatchObject({ - phase: "failed", - type: "error", - title: "Codex v1.1.0 update failed", - description: "command failed", - }); - }); - it("keeps unchanged providers actionable from settings", () => { const view = getProviderUpdateProgressToastView({ providers: [ @@ -444,31 +419,6 @@ describe("provider update launch notification logic", () => { }); }); - it("uses the updated version in the single-provider success toast title", () => { - const view = getSingleProviderUpdateProgressToastView( - provider({ - driver: driver("codex"), - version: "1.1.0", - latestVersion: "1.1.0", - advisoryStatus: "current", - updateState: { - status: "succeeded", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "Provider updated.", - output: null, - }, - }), - ); - - expect(view).toMatchObject({ - phase: "succeeded", - type: "success", - title: "Codex updated: v1.1.0", - description: "New sessions will use the updated provider.", - }); - }); - it("falls back to a rejected RPC message for transport-level failures", () => { const results = [AsyncResult.failure(Cause.die(new Error("WebSocket closed")))]; @@ -814,39 +764,6 @@ describe("provider update launch notification logic", () => { expect(snapshots).toEqual([primary]); }); - it("flags the first unsuccessful secondary outcome, skipping the primary and successes", () => { - const primaryFailed = provider({ - driver: driver("codex"), - updateState: terminalState("failed", "primary boom"), - }); - - expect( - firstUnsuccessfulSecondaryProviderOutcome([ - fulfilledOutcome(true, primaryFailed), - fulfilledOutcome( - false, - provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "ok"), - }), - ), - ]), - ).toBeNull(); - - expect( - firstUnsuccessfulSecondaryProviderOutcome([ - fulfilledOutcome(true, primaryFailed), - fulfilledOutcome( - false, - provider({ - driver: driver("codex"), - updateState: terminalState("failed", "wsl boom"), - }), - ), - ]), - ).toMatchObject({ status: "failed", provider: { updateState: { message: "wsl boom" } } }); - }); - it("treats a rejected dispatch as not contributing a snapshot", () => { const primary = provider({ driver: driver("codex"), @@ -995,14 +912,6 @@ describe("provider update launch notification logic", () => { }), ).toBe("My Device"); }); - - it("parses the WSL distro from the backend instance id", () => { - expect(parseWslDistroFromInstanceId("wsl:ubuntu")).toBe("ubuntu"); - expect(parseWslDistroFromInstanceId("wsl:default")).toBeNull(); - expect(parseWslDistroFromInstanceId("wsl:")).toBeNull(); - expect(parseWslDistroFromInstanceId("ssh:host")).toBeNull(); - expect(parseWslDistroFromInstanceId(undefined)).toBeNull(); - }); }); describe("isTerminalProviderUpdatePhase", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 10c2f144bff6..16184ac070bc 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -326,41 +326,6 @@ export function getProviderUpdateProgressToastView(input: { return getProviderUpdateRunningToastView(input.providerCount); } -export function getSingleProviderUpdateProgressToastView( - provider: ServerProvider, -): ProviderUpdateToastView { - const view = getProviderUpdateProgressToastView({ - providers: [provider], - providerCount: 1, - }); - const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; - - switch (view.phase) { - case "running": - return { - ...view, - title: `Updating ${providerName}`, - }; - case "failed": - return { - ...view, - title: getProviderFailedUpdateTitle(provider), - }; - case "unchanged": - return { - ...view, - title: `${providerName} still needs an update`, - }; - case "succeeded": - return { - ...view, - title: getProviderUpdatedTitle(provider), - }; - default: - return view; - } -} - export function collectUpdatedProviderSnapshots(input: { readonly results: ReadonlyArray< AtomCommandResult<{ readonly providers: ReadonlyArray }, unknown> @@ -649,42 +614,6 @@ export function collectProviderUpdateOutcomeSnapshots( return [...worstByDriver.values()]; } -/** - * The first secondary (non-primary) backend whose update resolved without - * succeeding. The primary's own failed/unchanged state is already surfaced - * inline in settings, so only secondaries (which have no inline row) need an - * explicit callout. - */ -export function firstUnsuccessfulSecondaryProviderOutcome( - results: ReadonlyArray>, -): { readonly provider: ServerProvider; readonly status: "failed" | "unchanged" } | null { - for (const result of results) { - if (result.status !== "fulfilled") { - continue; - } - const outcome = result.value; - if (outcome.isPrimary || outcome.provider === null) { - continue; - } - const status = outcome.provider.updateState?.status; - if (status === "failed" || status === "unchanged") { - return { provider: outcome.provider, status }; - } - } - return null; -} - -const WSL_INSTANCE_ID_PREFIX = "wsl:"; - -/** The distro name from a WSL backend instance id ("wsl:ubuntu" -> "ubuntu"), or null for the default. */ -export function parseWslDistroFromInstanceId(instanceId: string | undefined): string | null { - if (!instanceId || !instanceId.startsWith(WSL_INSTANCE_ID_PREFIX)) { - return null; - } - const distro = instanceId.slice(WSL_INSTANCE_ID_PREFIX.length).trim(); - return distro.length === 0 || distro === "default" ? null : distro; -} - /** * A human label that distinguishes local environments by platform (so the * popover shows "Windows" / "WSL" rather than the account name twice). WSL is From b7fc81dea2b7c963c34a365b82644ce963df19a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:30:19 -0700 Subject: [PATCH 049/103] refactor(web): remove unused terminal context preview formatter (#10009) --- apps/web/src/lib/terminalContext.test.ts | 15 ---------- apps/web/src/lib/terminalContext.ts | 35 ------------------------ 2 files changed, 50 deletions(-) diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 4b520c9bef4a..199054b1d84b 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { appendTerminalContextsToPrompt, - buildTerminalContextPreviewTitle, buildTerminalContextBlock, countInlineTerminalContextPlaceholders, deriveDisplayedUserMessageState, @@ -135,20 +134,6 @@ describe("terminalContext", () => { }); }); - it("returns null preview title when every context is invalid", () => { - expect( - buildTerminalContextPreviewTitle([ - makeContext({ - terminalId: " ", - }), - makeContext({ - id: "context-2", - text: "\n\n", - }), - ]), - ).toBeNull(); - }); - it("tracks inline terminal context placeholders in prompt text", () => { const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER; expect(countInlineTerminalContextPlaceholders(`a${placeholder}b${placeholder}`)).toBe(2); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 72f49a2f22d2..4cdbc019255d 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -65,20 +65,6 @@ export function filterTerminalContextsWithText( return contexts.filter((context) => hasTerminalContextText(context)); } -function previewTerminalContextText(text: string): string { - const normalized = normalizeTerminalContextText(text); - if (normalized.length === 0) { - return ""; - } - const lines = normalized.split("\n"); - const visibleLines = lines.slice(0, 3); - if (lines.length > 3) { - visibleLines.push("..."); - } - const preview = visibleLines.join("\n"); - return preview.length > 180 ? `${preview.slice(0, 177)}...` : preview; -} - export function normalizeTerminalContextSelection( selection: TerminalContextSelection, ): TerminalContextSelection | null { @@ -129,27 +115,6 @@ export function formatInlineTerminalContextLabel(selection: { return `@${terminalLabel}:${range}`; } -export function buildTerminalContextPreviewTitle( - contexts: ReadonlyArray, -): string | null { - if (contexts.length === 0) { - return null; - } - const previewParts: string[] = []; - for (const context of contexts) { - const normalized = normalizeTerminalContextSelection(context); - if (!normalized) continue; - const preview = previewTerminalContextText(normalized.text); - previewParts.push( - preview.length > 0 - ? `${formatTerminalContextLabel(normalized)}\n${preview}` - : formatTerminalContextLabel(normalized), - ); - } - const previews = previewParts.join("\n\n"); - return previews.length > 0 ? previews : null; -} - function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] { return normalizeTerminalContextText(selection.text) .split("\n") From 82c2b7ffb4d450572f5baf9a70693f1e25cfdfed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:32:27 -0700 Subject: [PATCH 050/103] refactor(web): test environment-scoped draft promotion (#10010) --- apps/web/src/composerDraftStore.test.ts | 54 +++++++++---------------- apps/web/src/composerDraftStore.ts | 35 ---------------- 2 files changed, 18 insertions(+), 71 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 8e7934a21211..53d07aab21d9 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -68,10 +68,7 @@ import { clearComposerDraftsEnvironment, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, - markPromotedDraftThread, markPromotedDraftThreadByRef, - markPromotedDraftThreads, - markPromotedDraftThreadsByRef, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, @@ -1200,7 +1197,7 @@ describe("composerDraftStore project draft thread mapping", () => { interactionMode: "plan", }); store.setPrompt(draftId, "keep this prompt"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, { threadId: retryThreadId, @@ -1363,12 +1360,12 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); - it("marks a promoted draft by thread id without deleting composer state", () => { + it("marks a promoted draft by scoped ref without deleting composer state", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); store.setPrompt(draftId, "promote me"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( @@ -1393,20 +1390,20 @@ describe("composerDraftStore project draft thread mapping", () => { const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); store.setPrompt(threadRef, "keep me"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); }); - it("marks promoted drafts from an iterable of server thread ids", () => { + it("promotes a draft without changing another thread's draft", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); store.setPrompt(draftId, "promote me"); store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId }); store.setPrompt(otherDraftId, "keep me"); - markPromotedDraftThreads([threadId]); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), @@ -1418,7 +1415,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(otherDraftId)?.prompt).toBe("keep me"); }); - it("marks every matching scoped draft when multiple environments share a thread id", () => { + it("promotes matching thread ids separately for each environment", () => { const store = useComposerDraftStore.getState(); const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId); @@ -1428,7 +1425,16 @@ describe("composerDraftStore project draft thread mapping", () => { store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId }); store.setPrompt(remoteDraftId, "remote draft"); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(localThreadRef); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(remoteProjectRef)?.threadId).toBe(threadId); + expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef); + expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toBeNull(); + expect(draftByKey(localDraftId)?.prompt).toBe("local draft"); + expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft"); + + markPromotedDraftThreadByRef(remoteThreadRef); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull(); @@ -1451,34 +1457,10 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)?.prompt).toBe("promote me"); }); - it("only marks iterable promotion cleanup entries for the matching environment refs", () => { - const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - store.setPrompt(draftId, "promote me"); - - markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]); - - expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( - threadId, - ); - expect(draftByKey(draftId)?.prompt).toBe("promote me"); - }); - - it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { - const store = useComposerDraftStore.getState(); - const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); - store.setPrompt(threadRef, "keep me"); - - markPromotedDraftThreads([threadId]); - - expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); - expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); - }); - it("moves composer edits made during promotion to the canonical thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); - markPromotedDraftThread(threadId); + markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); store.setPrompt(draftId, "typed during setup"); finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 189ccb4fe682..3adb6e45c2ae 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4106,29 +4106,6 @@ export function useEffectiveComposerModelState(input: { ); } -/** - * Mark a draft thread as promoting once the server has materialized the same thread id. - * - * Use the single-thread helper for live `thread.created` events and the - * iterable helper for bootstrap/recovery paths that discover multiple server - * threads at once. - */ -export function markPromotedDraftThread(threadId: ThreadId): void { - const store = useComposerDraftStore.getState(); - const draftThreadTargets: ComposerThreadTarget[] = []; - for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) { - if (draftThread.threadId === threadId) { - draftThreadTargets.push(DraftId.make(draftId)); - } - } - if (draftThreadTargets.length === 0) { - return; - } - for (const draftThreadTarget of draftThreadTargets) { - store.markDraftThreadPromoting(draftThreadTarget); - } -} - export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { const draftStore = useComposerDraftStore.getState(); for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { @@ -4141,18 +4118,6 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { } } -export function markPromotedDraftThreads(serverThreadIds: Iterable): void { - for (const threadId of serverThreadIds) { - markPromotedDraftThread(threadId); - } -} - -export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable): void { - for (const threadRef of serverThreadRefs) { - markPromotedDraftThreadByRef(threadRef); - } -} - export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { const draftStore = useComposerDraftStore.getState(); for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { From 6270a6f88bea4c2fe07a43e69693a918cf94a353 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:37:01 -0700 Subject: [PATCH 051/103] fix(web): retain wrapped row heights during edits (#10018) --- .../files/fileEditorVirtualization.test.ts | 723 ++++++++++++++++++ patches/@pierre%2Fdiffs@1.3.0-beta.10.patch | 117 ++- pnpm-lock.yaml | 10 +- 3 files changed, 843 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/files/fileEditorVirtualization.test.ts diff --git a/apps/web/src/components/files/fileEditorVirtualization.test.ts b/apps/web/src/components/files/fileEditorVirtualization.test.ts new file mode 100644 index 000000000000..cf293dd47254 --- /dev/null +++ b/apps/web/src/components/files/fileEditorVirtualization.test.ts @@ -0,0 +1,723 @@ +import { + getSharedHighlighter, + VirtualizedFile, + Virtualizer, + type FileContents, +} from "@pierre/diffs"; +import { Editor, TextDocument } from "@pierre/diffs/editor"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const renderingManagerUrl = new URL( + "./managers/UniversalRenderingManager.js", + import.meta.resolve("@pierre/diffs"), +); +const { clearRenderQueue } = (await import(/* @vite-ignore */ renderingManagerUrl.href)) as { + clearRenderQueue(): void; +}; + +// Layout measurements are controlled here. The real reconciler, document and +// renderer calculate positions. This does not simulate native CSS wrapping. +class MeasuredElement { + static geometryReads = 0; + children: MeasuredElement[] = []; + dataset: Record = {}; + nextElementSibling: MeasuredElement | null = null; + width = 283; + + constructor(readonly height = 0) {} + + getBoundingClientRect() { + MeasuredElement.geometryReads += 1; + return { top: 0, height: this.height, width: this.width }; + } +} + +class MeasuredCodeElement extends MeasuredElement { + readonly tagName = "CODE"; + + get firstElementChild() { + return this.children[0] ?? null; + } +} + +const observers: RecordedResizeObserver[] = []; +const animationFrames = new Map(); +let nextFrameId = 0; + +class RecordedResizeObserver { + readonly targets = new Set(); + + constructor(readonly callback: ResizeObserverCallback) { + observers.push(this); + } + + observe(target: Element) { + this.targets.add(target); + } + + unobserve(target: Element) { + this.targets.delete(target); + } + + disconnect() { + this.targets.clear(); + } + + deliver(target: HTMLElement) { + const rect = target.getBoundingClientRect(); + const size = { inlineSize: rect.width, blockSize: rect.height }; + this.callback( + [ + { + target, + contentRect: rect, + contentBoxSize: [size], + borderBoxSize: [size], + devicePixelContentBoxSize: [size], + }, + ], + this as unknown as ResizeObserver, + ); + } +} + +function drainRenderFrames() { + const errors = vi.spyOn(console, "error"); + try { + for (let frame = 0; animationFrames.size > 0; frame += 1) { + if (frame === 10) throw new Error("The production render queue did not settle"); + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + for (const callback of callbacks) callback(frame); + } + expect(errors).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + } +} + +function measuredElement(element: MeasuredElement): HTMLElement { + return element as unknown as HTMLElement; +} + +class LayoutVirtualizer extends Virtualizer { + override getOffsetInScrollContainer(_element: HTMLElement) { + return 0; + } +} + +class MeasuredFile extends VirtualizedFile { + override top = 0; + + override attachEditor(editor: Parameters[0]) { + this.editor = editor; + return () => { + this.editor = undefined; + }; + } + + async initialize(file: FileContents) { + this.prepareCodeViewItem(file, 0); + await this.fileRenderer.initializeHighlighter(); + expect( + this.fileRenderer.renderFile(file, { + startingLine: 5950, + totalLines: 51, + bufferBefore: 0, + bufferAfter: 0, + }), + ).toBeDefined(); + this.fileContainer = measuredElement(new MeasuredElement()); + } + + measure( + rows: ReadonlyArray, + contentWidth = 226.25, + ) { + const content = new MeasuredElement(); + content.width = contentWidth; + content.children = rows.map(([lineIndex, height]) => { + const row = new MeasuredElement(height); + row.dataset.lineIndex = String(lineIndex); + return row; + }); + const code = new MeasuredCodeElement(); + code.width = contentWidth + 56.75; + code.children = [new MeasuredElement(), content]; + this.code = measuredElement(code); + this.reconcileHeights(); + } + + resizeContent(contentWidth: number, codeWidth = contentWidth + 56.75) { + const code = this.code; + const content = code?.children[1]; + if (!(code instanceof MeasuredElement) || !(content instanceof MeasuredElement)) { + throw new Error("Expected measured code and content"); + } + code.width = codeWidth; + content.width = contentWidth; + } + + observeLayout() { + const pre = new MeasuredElement(); + if (!(this.code instanceof MeasuredElement)) throw new Error("Expected measured code"); + pre.children = [this.code]; + this.resizeManager.setup(measuredElement(pre) as HTMLPreElement, { + disableAnnotations: true, + columnVariables: "measure", + }); + const code = this.code; + const observer = observers.find((candidate) => candidate.targets.has(code)); + if (observer === undefined) throw new Error("The real resize manager did not observe code"); + return () => observer.deliver(code); + } + + dispose() { + this.fileContainer = undefined; + this.code = undefined; + this.cleanUp(); + } +} + +const instances: MeasuredFile[] = []; +const editors: Editor[] = []; + +beforeAll(async () => { + await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); +}); + +beforeEach(() => { + observers.length = 0; + animationFrames.clear(); + MeasuredElement.geometryReads = 0; + vi.stubGlobal("HTMLElement", MeasuredElement); + vi.stubGlobal("Document", MeasuredElement); + vi.stubGlobal("ResizeObserver", RecordedResizeObserver); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const id = ++nextFrameId; + animationFrames.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => animationFrames.delete(id)); +}); + +afterEach(() => { + for (const editor of editors.splice(0)) editor.cleanUp(); + for (const instance of instances.splice(0)) instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + vi.unstubAllGlobals(); +}); + +async function makeFixture( + overflow: "wrap" | "scroll" = "wrap", + lineCount = 6001, + contentWidth = 226.25, +) { + const contents = Array.from({ length: lineCount }, (_, index) => `line ${index}`).join("\n"); + const file: FileContents = { + name: "wrapped.txt", + contents, + cacheKey: `wrapped:${overflow}`, + lang: "text", + }; + const document = new TextDocument(file.name, contents, "text"); + const instance = new MeasuredFile( + { + overflow, + disableFileHeader: true, + theme: "pierre-dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, + controlledSelection: true, + }, + new LayoutVirtualizer(), + ); + instances.push(instance); + await instance.initialize(file); + instance.measure( + [ + [0, 80], + [120, 60], + [4999, 100], + [5000, 80], + [5999, 100], + [6000, 60], + ], + contentWidth, + ); + const apply = (change: { startLine: number } | undefined, passStartLine = true) => { + if (change === undefined) throw new Error("Expected a document change"); + file.contents = document.getText(); + instance.applyDocumentChange( + document, + undefined, + false, + passStartLine ? change.startLine : undefined, + ); + }; + const append = () => { + const position = document.positionAt(document.getText().length); + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + }; + return { instance, document, file, apply, append }; +} + +describe("wrapped editor document changes", () => { + it("preserves the position above an EOF insertion across layout checkpoints", async () => { + const { instance, document, append } = await makeFixture(); + const previousLastLine = document.lineCount; + const before = instance.getLinePosition(previousLastLine); + expect(before).toEqual({ top: 120328, height: 60 }); + const viewport = { top: before!.top - 100, bottom: before!.top + 80 }; + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 }); + + append(); + + expect(document.lineCount).toBe(previousLastLine + 1); + expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 }); + expect(instance.getVirtualizedHeight()).toBe(120376); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 }); + }); + + it("invalidates changed and shifted rows after an insertion in the middle", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5001); + const position = { line: 5000, character: 2 }; + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + + expect(instance.getLinePosition(5001)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(4999)).toBe(100); + expect(instance.getLineHeight(5000)).toBe(20); + expect(instance.getLineHeight(5999)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120208, height: 20 }); + }); + + it("keeps preceding measurements when a deletion crosses a checkpoint", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5000); + apply( + document.applyEdits([ + { + range: { start: { line: 4999, character: 2 }, end: { line: 5001, character: 2 } }, + newText: "", + }, + ]), + ); + + expect(document.lineCount).toBe(5999); + expect(instance.getLinePosition(5000)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLineHeight(4999)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120068, height: 20 }); + }); + + it("uses the earliest changed line for edits at multiple selections", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(121); + apply( + document.applyEdits( + [120, 5000].map((line) => ({ + range: { start: { line, character: 2 }, end: { line, character: 2 } }, + newText: "\n", + })), + ), + ); + + expect(document.lineCount).toBe(6003); + expect(instance.getLinePosition(121)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(4999)).toBe(20); + }); + + it("retains the unchanged prefix through repeated Enter, undo and redo", async () => { + const { instance, document, apply, append } = await makeFixture(); + const before = instance.getLinePosition(6001); + for (let count = 0; count < 60; count += 1) append(); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.undo()?.[0]); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.redo()?.[0]); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + }); + + it("keeps unwrapped positions unchanged", async () => { + const { instance, append } = await makeFixture("scroll"); + const before = instance.getLinePosition(6001); + append(); + expect(instance.getLinePosition(6001)).toEqual(before); + expect(instance.getLinePosition(6002)).toEqual({ top: 120028, height: 20 }); + }); + + it("fully invalidates measurements when the first changed line is unknown", async () => { + const { instance, document, apply } = await makeFixture(); + const position = document.positionAt(document.getText().length); + apply( + document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]), + false, + ); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it("still discards all measured rows after a metric change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 }); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 }); + }); + + it("still discards all measured rows when annotations change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }); +}); + +describe("wrapped measurement widths", () => { + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "drops offscreen measurements when content changes from %spx to %spx", + async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(60); + expect(instance.getVirtualizedHeight()).toBe(120076); + }, + ); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "does not retain old-width prefix heights after a %spx to %spx resize and edit", + async (before, after) => { + const { instance, append } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + append(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }, + ); + + it("repairs an edit before resize delivery when the real resize and render queues drain", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.25); + const readsBeforeEdit = MeasuredElement.geometryReads; + append(); + expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit); + // No synchronous geometry read: the resize entry owns invalidation. + expect(instance.getLineHeight(0)).toBe(80); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 }); + }); + + it("keeps measured prefixes through same-width reconciliation and editing", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + append(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); + }); + + it("preserves measurements on first and repeated same-width resize deliveries", async () => { + const { instance } = await makeFixture(); + const before = instance.getVirtualizedHeight(); + const deliverResize = instance.observeLayout(); + deliverResize(); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getVirtualizedHeight()).toBe(before); + }); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 20]], before); + const deliverResize = instance.observeLayout(); + instance.resizeContent(after); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("keeps width validity when a new editor attaches to the same file", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + const first = new Editor(); + editors.push(first); + first.edit(instance); + first.cleanUp(); + const second = new Editor(); + editors.push(second); + second.edit(instance); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("ignores stale resize deliveries after cleanup", async () => { + const { instance } = await makeFixture(); + const deliverResize = instance.observeLayout(); + instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + deliverResize(); + expect(animationFrames.size).toBe(0); + }); + + it("does not discard a stable code width for gutter subpixel rounding", async () => { + const { instance } = await makeFixture("wrap", 6001, 482.25); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.234375, 539); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + }); + + it("waits for a visible width instead of caching measurements while hidden", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(0, 0); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); +}); + +// Supply inert DOM transport so public Editor edits execute its real tokenizer +// and layout handoff. No native wrapping, observer delivery or scrolling is modeled. +class EditorElement extends MeasuredElement { + style: Record = {}; + parentElement: EditorElement | null = null; + + appendChild(child: EditorElement) { + child.parentElement = this; + this.children.push(child); + return child; + } + + prepend(child: EditorElement) { + child.parentElement = this; + this.children.unshift(child); + } + + replaceChildren(...children: (EditorElement | string)[]) { + this.children = []; + for (const child of children) if (typeof child !== "string") this.appendChild(child); + } + + setAttribute() {} + removeAttribute() {} + addEventListener() {} + removeEventListener() {} + after() {} + + remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((child) => child !== this); + } + } + + set innerHTML(value: string) { + expect(value.startsWith(" "code" in child.dataset); + } + + querySelector(selector: string) { + expect(selector).toBe("[data-deletions]"); + return null; + } + + getContext() { + return { measureText: (text: string) => ({ width: text.length * 8 }) }; + } +} + +async function makeEditorFixture(lineCount: number) { + const { instance, file } = await makeFixture("wrap", lineCount); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal("Document", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("getComputedStyle", () => ({ + paddingTop: "0px", + fontSize: "13px", + fontFamily: "monospace", + tabSize: "2", + lineHeight: "20px", + })); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + instance.setOptions({ + ...instance.options, + useTokenTransformer: true, + controlledSelection: true, + themeType: "dark", + }); + const content = new EditorElement(); + content.dataset.content = ""; + const gutter = new EditorElement(); + gutter.dataset.gutter = ""; + const code = new EditorElement(); + code.dataset.code = ""; + code.appendChild(gutter); + code.appendChild(content); + const shadow = new EditorElement(); + shadow.appendChild(code); + const host = Object.assign(new EditorElement(), { shadowRoot: shadow }); + const highlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); + const editor = new Editor(); + editors.push(editor); + editor.edit(instance); + editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, { + startingLine: 0, + totalLines: 1, + bufferBefore: 0, + bufferAfter: 0, + }); + const append = (count: number) => { + const lines = editor.getText().split("\n"); + const end = { line: lines.length - 1, character: lines.at(-1)!.length }; + editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]); + }; + const remove = (count: number) => { + const lines = editor.getText().split("\n"); + const startLine = lines.length - count - 1; + editor.applyEdits([ + { + range: { + start: { line: startLine, character: lines[startLine]!.length }, + end: { line: lines.length - 1, character: lines.at(-1)!.length }, + }, + newText: "", + }, + ]); + }; + return { instance, editor, append, remove }; +} + +describe("editor gutter-width changes", () => { + it.each([ + [9999, 1], + [9998, 3], + ])( + "clears prefix measurements when %i lines grow by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + expect(instance.getLineHeight(0)).toBe(80); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it.each([ + [10000, 1], + [10002, 4], + ])( + "clears prefix measurements when %i lines shrink by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, remove } = await makeEditorFixture(lines); + remove(count); + expect(editor.getText().split("\n")).toHaveLength(lines - count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it("clears newly measured prefixes on undo and redo across a digit boundary", async () => { + const { instance, editor, append } = await makeEditorFixture(9999); + append(1); + instance.measure([[0, 100]]); + editor.undo(); + expect(editor.getText().split("\n")).toHaveLength(9999); + expect(instance.getLineHeight(0)).toBe(20); + instance.measure([[0, 80]]); + editor.redo(); + expect(editor.getText().split("\n")).toHaveLength(10000); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it.each([ + [9998, 1], + [10000, 2], + ])( + "retains prefix measurements when %i lines grow by %i without changing digit width", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(5000)).toBe(80); + editor.undo(); + expect(instance.getLineHeight(0)).toBe(80); + editor.redo(); + expect(instance.getLineHeight(0)).toBe(80); + }, + ); +}); diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index 0c9819145d3b..5cf80feb3356 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -1,3 +1,79 @@ +diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +--- a/dist/components/VirtualizedFile.d.ts ++++ b/dist/components/VirtualizedFile.d.ts +@@ -42,7 +42,7 @@ declare class VirtualizedFile extends File { + private computeApproximateSize; + setVisibility(visible: boolean): void; + rerender(): void; +- applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; ++ applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; + protected renderPreparedFile({ + fileContainer, + file, +diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +--- a/dist/components/VirtualizedFile.js ++++ b/dist/components/VirtualizedFile.js +@@ -20,6 +20,7 @@ + cache = { + heights: /* @__PURE__ */ new Map(), + checkpoints: [], ++ codeWidth: void 0, + fileAnnotationHeight: 0 + }; + isVisible = false; +@@ -31,6 +32,8 @@ + super(options, workerManager, isContainerManaged); + this.virtualizer = virtualizer; + this.metrics = metrics; ++ const simpleVirtualizer = this.getSimpleVirtualizer(); ++ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); + } + setMetrics(metrics, force = false) { + if (!force && areObjectsEqual(this.metrics, metrics)) return; +@@ -70,10 +73,12 @@ + if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); + super.setThemeType(themeType); + } +- resetLayoutCache(recompute = false, resetRenderRange = true) { ++ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { + this.layoutDirty = true; +- this.cache.fileAnnotationHeight = 0; +- if (this.cache.heights.size > 0) this.cache.heights.clear(); ++ if (startLine === 0) this.cache.fileAnnotationHeight = 0; ++ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. ++ if (startLine === 0) this.cache.heights.clear(); ++ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); + if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; + if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; + if (recompute && this.isSimpleMode()) this.computeApproximateSize(); +@@ -91,6 +96,13 @@ + if (this.code == null) return hasHeightChange; + const content = this.code.children[1]; + if (!(content instanceof HTMLElement)) return hasHeightChange; ++ const codeWidth = this.code.getBoundingClientRect().width; ++ if (!(codeWidth > 0)) return hasHeightChange; ++ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { ++ this.resetLayoutCache(false, false); ++ hasHeightChange = true; ++ } ++ this.cache.codeWidth = codeWidth; + const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); + if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) { + const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; +@@ -287,11 +299,11 @@ + this.forceRenderOverride = true; + this.virtualizer.instanceChanged(this, false); + } +- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { + const previousRenderRange = this.renderRange; + super.applyDocumentChange(textDocument, newLineAnnotations); + this.getSimpleVirtualizer()?.markDOMDirty(); +- this.resetLayoutCache(this.isSimpleMode(), false); ++ this.resetLayoutCache(this.isSimpleMode(), false, startLine); + if (shouldUpdateBuffer && previousRenderRange !== void 0 && this.file !== void 0) { + const windowSpecs = this.virtualizer.getWindowSpecs(); + const renderRange = this.computeRenderRangeFromWindow(this.file, this.top ?? 0, windowSpecs); diff --git a/dist/editor/editor.js b/dist/editor/editor.js index ff78e2a..f9df318 100644 --- a/dist/editor/editor.js @@ -28,12 +104,18 @@ index ff78e2a..f9df318 100644 const gutterRow = resolveGutterTarget(e.composedPath()[0]); if (gutterRow?.dataset.lineType === "change-deletion") { const code = gutterRow.closest("[data-code]"); -@@ -1522,6 +1520,7 @@ var Editor = class { +@@ -1522,6 +1520,12 @@ var Editor = class { if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; } fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); + if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); - if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); +- if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); ++ if (didLineCountChange) { ++ const previousLineCount = change.lineCount - change.lineDelta; ++ // A wider or narrower line-number gutter can rewrap unchanged rows. ++ const layoutStartLine = String(previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; ++ fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); ++ } if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); if (newLineAnnotations !== void 0) { @@ -1788,6 +1787,7 @@ var Editor = class { @@ -44,6 +126,37 @@ index ff78e2a..f9df318 100644 try { this.#fileInstance?.setSelectedLines(range, { notify: false, +diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +--- a/dist/managers/ResizeManager.d.ts ++++ b/dist/managers/ResizeManager.d.ts +@@ -5,6 +5,8 @@ + columnVariables?: ResizeManagerColumnVariableMode; + } + declare class ResizeManager { ++ /** Schedule owner measurement after an observed code or gutter size change. */ ++ onResize?: () => void; + private static resizeObserver; + private static managersByElement; + private static getResizeObserver; +diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +--- a/dist/managers/ResizeManager.js ++++ b/dist/managers/ResizeManager.js +@@ -19,6 +19,7 @@ + for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); + } + observedNodes = /* @__PURE__ */ new Map(); ++ onResize; + setup(pre, { disableAnnotations, columnVariables = "apply" }) { + const annotationUpdates = /* @__PURE__ */ new Set(); + const applyColumnVariables = columnVariables === "apply"; +@@ -212,6 +213,7 @@ + this.applyAnnotationUpdates(annotationUpdates); + annotationUpdates.clear(); + this.applyColumnUpdates(codeUpdates); ++ if (codeUpdates.size > 0) this.onResize?.(); + codeUpdates.clear(); + } + applyAnnotationUpdates(annotationUpdates) { diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js index e9f62f5..af82a46 100644 --- a/dist/react/utils/useFileInstance.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 595627a489ca..bd6ed98740aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,7 +92,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 - '@pierre/diffs@1.3.0-beta.10': c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4 + '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -242,7 +242,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -583,7 +583,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -14028,7 +14028,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) @@ -14042,7 +14042,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) From cb58dfd6453171a88183fc7bf1587bae6929c0e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:41:03 -0700 Subject: [PATCH 052/103] refactor(shared): remove unused Clerk hostname predicate (#10008) --- packages/shared/src/relayAuth.test.ts | 11 ----------- packages/shared/src/relayAuth.ts | 11 ----------- 2 files changed, 22 deletions(-) diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index 3abff9b52109..4e1f28eefdaa 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -5,7 +5,6 @@ import { ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, clerkFrontendApiUrlFromPublishableKey, - isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; @@ -75,14 +74,4 @@ describe("Clerk relay auth", () => { }); expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); - - it("allows standard Clerk hosts and an exact configured custom hostname", () => { - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.dev", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.com", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("clerk.t3.codes", "clerk.t3.codes")).toBe(true); - expect(isAllowedClerkFrontendApiHostname("attacker.example", "clerk.t3.codes")).toBe(false); - expect(isAllowedClerkFrontendApiHostname("nested.clerk.t3.codes", "clerk.t3.codes")).toBe( - false, - ); - }); }); diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index a384db77d8ac..4c5d766c1480 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -81,17 +81,6 @@ export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: strin return parseClerkFrontendApi(publishableKey).hostname; } -export function isAllowedClerkFrontendApiHostname( - hostname: string, - configuredHostname: string | null, -): boolean { - return ( - hostname.endsWith(".clerk.accounts.dev") || - hostname.endsWith(".clerk.accounts.com") || - hostname === configuredHostname - ); -} - export function relayClerkTokenOptions(template: string) { return { template, From 1d58f2ecc4b2f6cea9897dcf46734af6210d053e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:42:26 -0700 Subject: [PATCH 053/103] refactor(tailscale): keep package internals private (#10011) --- packages/tailscale/src/tailscale.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index d6db5e8bcc59..7f1cf41661fb 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -9,8 +9,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); -export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); -export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); +const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); +const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. @@ -47,7 +47,7 @@ const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< ]; /** Classifies stderr into a safe label, dropping the text itself. */ -export const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { +const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { if (stderr.trim().length === 0) { return undefined; } @@ -66,7 +66,7 @@ export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( +class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( "TailscaleCommandOutputError", { ...TailscaleCommandContext, @@ -137,7 +137,6 @@ const TailscaleStatusJson = Schema.Struct({ Self: Schema.optional(TailscaleStatusSelf), }); -export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { From cd92a7e7ae68a9b0c01938e1f69c2044b6c455ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:42:26 -0700 Subject: [PATCH 054/103] ci: reject unused tailscale exports with Knip (#10012) --- .github/workflows/ci.yml | 4 ++-- docs/operations/development.md | 6 ++++-- package.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6d07c653040..4bcb1e21ab99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,8 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron - # Export cleanup is still a manual audit; files and dependencies have no baseline. - - name: Check unused files and dependencies + # Files/dependencies are repo-wide; export checks cover clean workspaces only. + - name: Check unused code run: vp run knip:check - name: Check diff --git a/docs/operations/development.md b/docs/operations/development.md index d415badb61d0..de5c6c64e3ab 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -71,10 +71,12 @@ Windows investigation while that suite is not a required gate. ### Unused code -`vp run knip:check` runs the unused-file and dependency check enforced by CI. +`vp run knip:check` checks unused files and dependencies across the repo, then +unused exports and types in `packages/tailscale`. CI enforces both checks. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. -The full export audit still has findings and is not a CI gate. Review callers before +The full export audit still has findings and is not a repo-wide CI gate. Extend the +export check's workspace selectors as more workspaces become clean. Review callers before deleting code; production mode can also report development scripts and test fixtures. Runtime-discovered entrypoints and dependency exceptions belong in [knip.jsonc](../../knip.jsonc). diff --git a/package.json b/package.json index 20f3f30837b2..2882a1f9ab7e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip", - "knip:check": "knip --include files,dependencies --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --exports --no-config-hints", "knip:production": "knip --production", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From eced382b451abc35137e7047b010c6ae5f6e9353 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:55:35 -0700 Subject: [PATCH 055/103] fix(web): keep chat media at a stable size while it loads (#9938) Co-authored-by: Claude Code --- apps/web/src/components/ChatMarkdown.tsx | 322 +++++++++++++----- .../ChatMarkdown.workspace-images.test.tsx | 78 ++++- .../src/components/media/MediaVideoPlayer.tsx | 4 +- 3 files changed, 307 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 614c404d978e..af41324be6a7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -52,6 +52,7 @@ import React, { Children, Suspense, type CSSProperties, + type ComponentProps, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, @@ -357,6 +358,55 @@ type MarkdownImageHastNode = { children?: MarkdownImageHastNode[]; }; +function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { + return (node.children ?? []).filter( + (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), + ); +} + +/** + * An image that is the only content of its block (optionally wrapped in a + * link) is almost always a screenshot or figure, so it gets a reserved slot + * while it loads. Images mixed with text or other images — badge rows, icons + * in a sentence — stay inline at their natural size, since a placeholder taller + * than the image would move the page more than the image itself does. + */ +/** Containers whose sole child image reads as a figure rather than part of a sentence. */ +const STANDALONE_IMAGE_BLOCKS = new Set([ + "p", + "div", + "li", + "td", + "th", + "figure", + "center", + "blockquote", +]); + +function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { + const children = meaningfulHastChildren(node); + if (children.length !== 1) return undefined; + const only = children[0]; + if (only?.type !== "element") return undefined; + if (only.tagName === "img") return only; + // A link, emphasis, or similar inline wrapper around the image still counts + // as long as nothing else shares the block. + return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" + ? soleImageDescendant(only) + : undefined; +} + +function markStandaloneImages(node: MarkdownImageHastNode) { + // A raw `` on its own line reaches the root without a paragraph. + if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { + const image = soleImageDescendant(node); + if (image) image.properties = { ...image.properties, dataStandalone: true }; + } + node.children?.forEach((child) => { + if (child.type === "element") markStandaloneImages(child); + }); +} + /** Carries authored image source metadata through the sanitizer to the image renderer. */ function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { @@ -374,6 +424,7 @@ function rehypePreserveImageSourceMeta() { }; visit(tree); + markStandaloneImages(tree); }; } @@ -386,7 +437,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], - img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], + img: [ + ...(defaultSchema.attributes?.img ?? []), + "dataLocalSrc", + "dataMarkdownTitle", + "dataStandalone", + ], }, protocols: { ...defaultSchema.protocols, @@ -1200,7 +1256,6 @@ function authoredImageSizeStyle( } const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( - CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); @@ -1242,13 +1297,26 @@ function expandableMarkdownImageProps( }; } +function ChatMarkdownMediaUnavailableLabel(props: { + readonly alt: string; + readonly kind?: "image" | "video" | undefined; +}) { + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + return ( + + + {props.alt.length > 0 ? `${label} · ${props.alt}` : label} + + ); +} + +/** Inline chip for an image that sits in a line of text or can never load. */ function ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; readonly kind?: "image" | "video"; - readonly actionsSource?: MediaActionSource; + readonly actionsSource?: MediaActionSource | undefined; }) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; const content = ( - - - {props.alt.length > 0 ? `${label} · ${props.alt}` : label} - + ); return props.actionsSource ? ( @@ -1270,6 +1335,144 @@ function ChatMarkdownImageFallback(props: { ); } +const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( + "aspect-video w-full overflow-hidden bg-muted/60", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, +); + +/** + * A standalone image holds a 16:9 slot (or its authored size) until it has + * decoded, and keeps that slot if it fails, so a timeline row moves at most + * once: when the natural size arrives. A bare `` is zero height until + * then. Once decoded the image renders bare again so its box, hit area, and + * alignment are exactly the image's own. Inline images (badges, icons in a + * sentence) skip the slot: a placeholder taller than the image would move the + * page more than the image does. + * + * Callers key this on the file's identity, not its URL: a re-signed URL for + * the same file keeps the decoded image on screen while the new bytes arrive, + * and a different file starts from the slot again. + */ +function ChatMarkdownImage(props: { + /** Null while the URL is being resolved; the last decoded image stays up. */ + readonly src: string | null; + readonly sourceFailed?: boolean | undefined; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly standalone: boolean; + readonly className?: string | undefined; + readonly style?: CSSProperties | undefined; + /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ + readonly imageProps?: + | Omit, "src" | "alt" | "className" | "style"> + | undefined; + readonly actionsSource: MediaActionSource; + readonly originalUrl?: string | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + const [loadedSrc, setLoadedSrc] = useState(null); + const [failedSrc, setFailedSrc] = useState(null); + const src = props.src ?? loadedSrc; + const failed = props.sourceFailed === true || (src !== null && failedSrc === src); + // A failure forgets the decoded image so the next URL loads behind the slot. + const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); + // Cached images are complete before `onLoad` can fire. + const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { + if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); + }, []); + const imageEvents = (loadingSrc: string) => ({ + onLoad: () => { + setLoadedSrc(loadingSrc); + setFailedSrc(null); + }, + onError: () => { + setFailedSrc(loadingSrc); + setLoadedSrc(null); + }, + }); + + if (settled) { + return ( + + {props.alt} + + ); + } + if (!props.standalone) { + return failed ? ( + + ) : ( + + ); + } + return ( + + + {failed ? ( + + + + ) : src !== null ? ( + {props.alt} + ) : null} + + + ); +} + function ChatMarkdownVideo(props: { readonly src: string | null; readonly alt: string; @@ -1316,13 +1519,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly alt: string; readonly copyMarkdown?: string; readonly srcFragment?: string; + /** Reserve a slot while loading; off for images that share a line with text. */ + readonly standalone?: boolean | undefined; readonly style?: CSSProperties | undefined; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const [failedUrl, setFailedUrl] = useState(null); const resource = props.resource; const path = resource._tag === "media-file" @@ -1367,56 +1571,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props ); } - if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ( - - ); - } - if (assetUrl._tag !== "Success") { - return ( - - - - ); - } return ( - - {props.alt} setFailedUrl(assetUrl.url)} - /> - + ); }); @@ -2772,6 +2939,7 @@ const CHAT_MARKDOWN_COMPONENTS = { const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; + const standalone = node?.properties?.dataStandalone === true; const authoredSrc = typeof localSrc === "string" ? localSrc : src; const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; const srcString = @@ -2780,7 +2948,8 @@ const CHAT_MARKDOWN_COMPONENTS = { typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; const altText = alt ?? ""; const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); - const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); + const { className, style: _style, width, height, ...imageProps } = props; + const authoredSizeStyle = authoredImageSizeStyle(width, height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { @@ -2807,27 +2976,19 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } return ( - - {altText} - + ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { @@ -2843,6 +3004,7 @@ const CHAT_MARKDOWN_COMPONENTS = { kind={kind} copyMarkdown={copyMarkdown} srcFragment={markdownImageSourceFragment(classifiedSrc)} + standalone={standalone} style={authoredSizeStyle} workspaceRoot={cwd} onImageExpand={imageExpand} diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39be0eedafe2..ad3e951d89a9 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -139,9 +139,10 @@ describe("ChatMarkdown workspace images", () => { path: "\\\\server\\share\\workspace-image.svg", }, ]); - expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); + expect(html.match(/]*src="https:\/\/signed\.test\/workspace-image\.svg"/g)).toHaveLength( + 4, + ); expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); - expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); expect(html).not.toContain("Image unavailable"); }); @@ -203,23 +204,49 @@ describe("ChatMarkdown workspace images", () => { expect(loadedStyle).toHaveProperty(constraint, expectedValue); }); - it("keeps all images baseline-aligned and workspace images inline", () => { + it("keeps images that share a line inline and lets a standalone one reserve a slot", () => { const html = render( "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)", ); - const classNames = Array.from(html.matchAll(/]*class="([^"]*)"/g), (match) => - match[1]?.split(" "), - ); - expect(classNames).toHaveLength(2); - expect(classNames[1]).toContain("inline-block!"); + // Two images in one paragraph are badges: neither reserves a slot. + expect(html).not.toContain("aspect-video"); + expect(html).toContain('src="https://example.com/badge.svg"'); + expect(html).toContain('src="https://signed.test/workspace-image.svg"'); + expect(html.match(/]*class="[^"]*inline-block![^"]*"/g)).toHaveLength(1); + expect(html).not.toContain("invisible"); const centeredHtml = render( '

logo

', ); - const centeredClassName = /]*class="([^"]*)"/.exec(centeredHtml)?.[1]; + const frame = /]*role="status"[^>]*>/.exec(centeredHtml)?.[0]; + + expect(frame).toContain("inline-block!"); + expect(frame).toContain("aspect-video"); + }); + + it("reserves a slot for an image that is the only content of its link", () => { + const html = render("[![shot](.t3/workspace-image.svg)](https://example.com)"); + + expect(html).toContain("aspect-video"); + }); + + it.each([ + ["a link", "Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"], + ["emphasis", "**![shot](.t3/workspace-image.svg)** caption"], + ])("keeps an image wrapped in %s inline when text shares its block", (_wrapper, markdown) => { + expect(render(markdown)).not.toContain("aspect-video"); + }); + + it("keeps an authored id on a remote image so fragment links resolve", () => { + const html = render('diagram'); + + // The sanitizer prefixes authored ids; the loading slot carries it too. + expect(html).toContain(' { + expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video"); }); it("retains an authored SVG fragment on the signed URL", () => { @@ -278,15 +305,35 @@ describe("ChatMarkdown workspace images", () => { ); }); - it("uses a static bounded-width placeholder while a signed asset URL loads", () => { + it("reserves the same 16:9 frame while the URL, the bytes, and a failure resolve", () => { + const frameClassName = (html: string) => { + const frame = /]*role="(?:status|alert)"[^>]*>/.exec(html)?.[0] ?? ""; + return /class="([^"]*)"/.exec(frame)?.[1]?.split(" ") ?? []; + }; + const markdown = "![shot](.t3/workspace-image.svg)"; + testState.assetState = "loading"; + const loadingUrl = frameClassName(render(markdown)); + testState.assetState = "success"; + const loadingBytes = render(markdown); + testState.assetState = "failure"; + const failure = render(markdown); + + expect(loadingUrl).toEqual(expect.arrayContaining(["aspect-video", "w-full"])); + expect(loadingUrl).not.toContain("animate-pulse"); + expect(frameClassName(loadingBytes)).toEqual(loadingUrl); + expect(frameClassName(failure)).toEqual(loadingUrl); + expect(failure).toContain("Image unavailable"); + // The bytes are requested inside the frame but never paint at an unknown size. + expect(loadingBytes).toMatch(/]*src="https:\/\/signed[^>]*class="invisible/); + expect(loadingBytes).not.toContain('loading="lazy"'); + }); - const html = render("![loading](.t3/workspace-image.svg)"); - const className = /]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; + it("gives a standalone remote image the same frame instead of a bare tag", () => { + const html = render("![remote](https://example.com/shot.png)"); expect(html).toContain('aria-label="Loading image"'); - expect(html).not.toContain("animate-pulse"); - expect(className?.split(" ")).toContain("w-64"); + expect(html).toContain("aspect-video"); }); it("never passes a workspace source to a raw image when thread context is unavailable", () => { @@ -313,7 +360,6 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([]); expect(html).toContain('src="https://example.com/image.png"'); expect(html).toContain("max-w-[min(100%,30rem)]"); - expect(html).toContain("max-h-[30rem]"); expect(html).not.toContain("Image unavailable"); }); }); diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index f436beeb3855..90c18984e17b 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -127,7 +127,9 @@ export function MediaVideoPlayer({ From 31fb21009024e9476bda4705355d55491d7fd2ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:19:37 -0700 Subject: [PATCH 056/103] refactor(server): keep manifest age parsing private (#10028) --- apps/server/src/provider/ModelManifest.test.ts | 2 -- apps/server/src/provider/ModelManifest.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 73049ad01c30..bb592a0a6fc8 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -17,7 +17,6 @@ import { make, resolveProviderCatalog, type ModelManifestData, - manifestUpdatedAtMs, encodeManifestCache, } from "./ModelManifest.ts"; @@ -382,7 +381,6 @@ describe("ModelManifest service", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; - assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0); const cachePath = path.join(config.stateDir, "model-manifest.json"); // A cache of the manifest as it was before the release edited it. The // fetch time is irrelevant: the remote may be unreachable now, so diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index c3cb36566c02..67a7334f613d 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -138,7 +138,7 @@ export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); /** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ -export function manifestUpdatedAtMs(manifest: ModelManifestData): number { +function manifestUpdatedAtMs(manifest: ModelManifestData): number { if (manifest.updatedAt === undefined) return 0; const parsed = Date.parse(manifest.updatedAt); return Number.isNaN(parsed) ? 0 : parsed; From 37bf4e6ec4f18fb94e7ec04436a947d14642a5b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:19:42 -0700 Subject: [PATCH 057/103] refactor(mobile): remove unused awareness relay URL normalizer (#10029) --- .../agent-awareness/remoteRegistration.test.ts | 8 -------- .../src/features/agent-awareness/remoteRegistration.ts | 10 ---------- 2 files changed, 18 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 582c58fb27e6..152948274ca3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -33,7 +33,6 @@ import { mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, - normalizeAgentAwarenessRelayBaseUrl, registerAgentAwarenessConnection, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, @@ -363,13 +362,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); - it("normalizes relay base URLs for APNs registration requests", () => { - expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); - }); - it("overrides persisted preferences for an in-flight registration", () => { expect( mergeAgentAwarenessRegistrationPreferences( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2d4261de603..9f4539c44d64 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -139,16 +139,6 @@ export function mergeAgentAwarenessRegistrationPreferences( return { ...stored, ...override }; } -export function normalizeAgentAwarenessRelayBaseUrl( - value: string | null | undefined, -): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayConfig(): { readonly url: string } | null { const relayUrl = resolveCloudPublicConfig().relay.url; if (!relayUrl) { From f5d9d12026d98b2b4b975f1944e7503d6c2c9eb3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:19:47 -0700 Subject: [PATCH 058/103] refactor(server): remove unused startup heartbeat launcher (#10030) --- apps/server/src/serverRuntimeStartup.test.ts | 51 -------------------- apps/server/src/serverRuntimeStartup.ts | 8 --- 2 files changed, 59 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 0b909d96f43d..fddf618cb13b 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -13,7 +13,6 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -103,56 +102,6 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => - Effect.scoped( - Effect.gen(function* () { - const releaseCounts = yield* Deferred.make(); - const countsStarted = yield* Deferred.make(); - - yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { - getUserInputActivity: () => Effect.die("unused"), - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.die("unused"), - getEventReplayStats: () => Effect.die("unused"), - getCounts: () => - Deferred.succeed(countsStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCounts)), - Effect.as({ - projectCount: 2, - threadCount: 3, - }), - ), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getThreadRuntimeContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), - searchThreads: () => Effect.succeed({ matches: [] }), - }), - Effect.provideService(AnalyticsService.AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }), - ); - - // The heartbeat is forked, so the caller is already back here while - // getCounts is still parked. Awaiting countsStarted proves the forked - // work really ran; releaseCounts staying incomplete proves the caller - // never waited for it. - yield* Deferred.await(countsStarted); - assert.equal(yield* Deferred.isDone(releaseCounts), false); - }), - ), -); - it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7a5e7b12b865..ea3670f08c9f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -168,14 +168,6 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - Effect.asVoid, -); - const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, From 86f079964b9c3d2a087907d2ef8273c3dbcb960b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:09 -0700 Subject: [PATCH 059/103] refactor(shared): keep search ranking comparator private (#10031) --- packages/shared/src/searchRanking.test.ts | 2 -- packages/shared/src/searchRanking.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index 7e2ccce6e063..8ddf02ec8498 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - compareRankedSearchResults, insertRankedSearchResult, normalizeSearchQuery, scoreQueryMatch, @@ -90,6 +89,5 @@ describe("insertRankedSearchResult", () => { insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2); expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]); - expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0); }); }); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index b2fb2e223d3b..c8ec69e39703 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -135,7 +135,7 @@ export function scoreQueryMatch(input: { return null; } -export function compareRankedSearchResults( +function compareRankedSearchResults( left: RankedSearchResult, right: RankedSearchResult, ): number { From 68aa7aa8305e9dfc0d98543f607d88bd37fb66e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:46 -0700 Subject: [PATCH 060/103] refactor(server): keep telemetry identity errors private (#10032) --- apps/server/src/telemetry/Identify.test.ts | 20 -------------------- apps/server/src/telemetry/Identify.ts | 4 ++-- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index ab151821789a..92d3223267d7 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -33,26 +33,6 @@ const findIdentityLog = ( errorTag: string, ) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); -it("preserves exact telemetry identity causes without deriving messages from them", () => { - const decodeCause = new Error("private nested decode details"); - const decodeError = new Identify.TelemetryIdentityDecodeError({ - source: "codex", - filePath: "/tmp/auth.json", - cause: decodeCause, - }); - const readCause = new Error("private nested read details"); - const readError = new Identify.TelemetryIdentityReadError({ - source: "anonymous", - filePath: "/tmp/anonymous-id", - cause: readCause, - }); - - assert.strictEqual(decodeError.cause, decodeCause); - assert.strictEqual(readError.cause, readCause); - assert.notInclude(decodeError.message, decodeCause.message); - assert.notInclude(readError.message, readCause.message); -}); - it.layer(NodeServices.layer)("telemetry identity", (it) => { it.effect("uses the persisted anonymous id when provider identities are absent", () => Effect.gen(function* () { diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index b6c3d0066dff..15d3bf13f782 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -23,7 +23,7 @@ const ClaudeJsonSchema = Schema.Struct({ export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; -export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( "TelemetryIdentityReadError", { source: TelemetryIdentitySource, @@ -36,7 +36,7 @@ export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( "TelemetryIdentityDecodeError", { source: Schema.Literals(["codex", "claude"]), From 83a2897ed6c400b3679a37920e964d829094bd43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:50 -0700 Subject: [PATCH 061/103] refactor(mobile): test composer persistence through the live decoder (#10033) --- .../src/state/use-composer-drafts.test.ts | 17 ++++++++--------- apps/mobile/src/state/use-composer-drafts.ts | 4 ---- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index e355e0e6dd7f..a5e85227e271 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -159,7 +159,6 @@ import { copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, - decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, @@ -252,12 +251,12 @@ describe("mobile composer drafts", () => { }; expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); @@ -987,7 +986,7 @@ describe("mobile composer drafts", () => { it("rejects persisted images without image bytes or a file URI", () => { expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { @@ -1010,7 +1009,7 @@ describe("mobile composer drafts", () => { it("hydrates selector state even when the message content is empty", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": { @@ -1030,7 +1029,7 @@ describe("mobile composer drafts", () => { }, }, }, - }), + }).drafts, ).toEqual({ "new-task:environment-1:project-1": { text: "", @@ -1053,18 +1052,18 @@ describe("mobile composer drafts", () => { it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": DRAFT, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": DRAFT, }); expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 065331d67064..6b463c2d2624 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -238,10 +238,6 @@ export function decodePersistedComposerState(value: unknown): { }; } -export function decodePersistedComposerDrafts(value: unknown): Record { - return decodePersistedComposerState(value).drafts; -} - async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); From 5cb696595cd66bb00e03fa5f91d9cd4273949f5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:54 -0700 Subject: [PATCH 062/103] refactor(web): remove unused sidebar selectors (#10034) --- apps/web/src/components/Sidebar.logic.test.ts | 144 ------------------ apps/web/src/components/Sidebar.logic.ts | 82 ---------- 2 files changed, 226 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bf3d37d0edc2..cd56835ffd8b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -9,11 +9,9 @@ import { createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, - getVisibleSidebarThreadIds, resolveAdjacentThreadId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, - getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, @@ -27,7 +25,6 @@ import { resolveWorkingStartedAt, searchSidebarThreadsByTitle, formatWorkingDurationLabel, - shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, @@ -84,56 +81,6 @@ describe("animatePinnedLayoutChanges", () => { }); }); -describe("shouldNavigateAfterProjectRemoval", () => { - const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; - - it("navigates away from a draft route owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-1" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(true); - }); - - it("does not navigate away from a different draft route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-2" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(false); - }); - - it("navigates away from a server thread owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { - kind: "server", - threadRef: { - environmentId: EnvironmentId.make("environment-local"), - threadId: ThreadId.make("thread-1"), - }, - }, - projectThreads, - projectDraftId: null, - }), - ).toBe(true); - }); - - it("does not navigate from an unrelated route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: null, - projectThreads, - projectDraftId: null, - }), - ).toBe(false); - }); -}); - describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; @@ -644,46 +591,6 @@ describe("resolveAdjacentThreadId", () => { }); }); -describe("getVisibleSidebarThreadIds", () => { - it("returns only the rendered visible thread order across projects", () => { - expect( - getVisibleSidebarThreadIds([ - { - renderedThreadIds: [ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ], - }, - { - renderedThreadIds: [ThreadId.make("thread-8"), ThreadId.make("thread-6")], - }, - ]), - ).toEqual([ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ThreadId.make("thread-8"), - ThreadId.make("thread-6"), - ]); - }); - - it("skips threads from collapsed projects whose thread panels are not shown", () => { - expect( - getVisibleSidebarThreadIds([ - { - shouldShowThreadPanel: false, - renderedThreadIds: [ThreadId.make("thread-hidden-2"), ThreadId.make("thread-hidden-1")], - }, - { - shouldShowThreadPanel: true, - renderedThreadIds: [ThreadId.make("thread-12"), ThreadId.make("thread-11")], - }, - ]), - ).toEqual([ThreadId.make("thread-12"), ThreadId.make("thread-11")]); - }); -}); - describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( @@ -1362,57 +1269,6 @@ describe("resolveProjectStatusIndicator", () => { }); }); -describe("getVisibleThreadsForProject", () => { - it("includes the active thread even when it falls below the folded preview", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - title: `Thread ${index + 1}`, - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: false, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ThreadId.make("thread-5"), - ThreadId.make("thread-6"), - ThreadId.make("thread-8"), - ]); - expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ThreadId.make("thread-7")]); - }); - - it("returns all threads when the list is expanded", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: true, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual( - threads.map((thread) => thread.id), - ); - expect(result.hiddenThreads).toEqual([]); - }); -}); - function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8e88aea37f4c..92cdf72c51fe 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -12,7 +12,6 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -407,17 +406,6 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } -export function getVisibleSidebarThreadIds( - renderedProjects: readonly { - shouldShowThreadPanel?: boolean; - renderedThreadIds: readonly TThreadId[]; - }[], -): TThreadId[] { - return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, - ); -} - export function getSidebarThreadIdsToPrewarm( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, @@ -452,28 +440,6 @@ export function resolveAdjacentThreadId(input: { return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } -export function shouldNavigateAfterProjectRemoval(input: { - routeTarget: ThreadRouteTarget | null; - projectThreads: readonly { - environmentId: string; - id: string; - }[]; - projectDraftId: string | null; -}): boolean { - const { projectDraftId, projectThreads, routeTarget } = input; - if (routeTarget?.kind === "draft") { - return projectDraftId === routeTarget.draftId; - } - if (routeTarget?.kind !== "server") { - return false; - } - return projectThreads.some( - (thread) => - thread.environmentId === routeTarget.threadRef.environmentId && - thread.id === routeTarget.threadRef.threadId, - ); -} - export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; @@ -838,54 +804,6 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject>(input: { - threads: readonly T[]; - activeThreadId: T["id"] | undefined; - isThreadListExpanded: boolean; - previewLimit: number; -}): { - hasHiddenThreads: boolean; - visibleThreads: T[]; - hiddenThreads: T[]; -} { - const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; - const hasHiddenThreads = threads.length > previewLimit; - - if (!hasHiddenThreads || isThreadListExpanded) { - return { - hasHiddenThreads, - hiddenThreads: [], - visibleThreads: [...threads], - }; - } - - const previewThreads = threads.slice(0, previewLimit); - if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const activeThread = threads.find((thread) => thread.id === activeThreadId); - if (!activeThread) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); - - return { - hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), - visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), - }; -} - export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { From ad3721eb5bec31066d162916f700107aab5e125b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:22:08 -0700 Subject: [PATCH 063/103] refactor(server): keep Cursor fallback models private (#10038) --- .../server/src/provider/Layers/CursorProvider.test.ts | 11 ----------- apps/server/src/provider/Layers/CursorProvider.ts | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 78edd8acbd45..4fa382c788d5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -16,7 +16,6 @@ import { buildCursorCapabilitiesFromConfigOptions, checkCursorProviderStatus, discoverCursorModelsViaAcp, - getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, parseCursorCliConfigChannel, @@ -475,16 +474,6 @@ describe("Cursor skills", () => { }); }); -describe("getCursorFallbackModels", () => { - it("does not publish any built-in cursor models before ACP discovery", () => { - expect( - getCursorFallbackModels({ - customModels: ["internal/cursor-model"], - }).map((model) => model.slug), - ).toEqual(["internal/cursor-model"]); - }); -}); - describe("buildCursorProviderSnapshot", () => { it("downgrades ready status to warning when ACP model discovery times out", () => { expect( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index e6c7853844e0..48fbfe27c482 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -572,7 +572,7 @@ export const discoverCursorModelsViaAcp = ( environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); -export function getCursorFallbackModels( +function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); From 07fb04dc63694876b531a7bb5492a72b8282009d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:22:57 -0700 Subject: [PATCH 064/103] refactor(web): remove unused xterm link range helpers (#10040) --- apps/web/src/terminal-links.test.ts | 42 -------------------------- apps/web/src/terminal-links.ts | 47 ----------------------------- 2 files changed, 89 deletions(-) diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 34c6c9830a0a..3c466378ba8b 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -6,8 +6,6 @@ import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget, - resolveWrappedTerminalLinkRange, - wrappedTerminalLinkRangeIntersectsBufferLine, type TerminalBufferLineLike, } from "./terminal-links"; @@ -154,46 +152,6 @@ describe("collectWrappedTerminalLinkLine", () => { }); }); -describe("resolveWrappedTerminalLinkRange", () => { - it("maps wrapped URL matches back to the correct buffer rows", () => { - const prefix = "see "; - const firstSegment = `${prefix}https://example.com/a`; - const secondSegment = "/bc?x=1"; - const lines = [ - createBufferLine("prompt> "), - createBufferLine(firstSegment), - createBufferLine(secondSegment, true), - ]; - const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]); - - expect(wrappedLine).not.toBeNull(); - if (!wrappedLine) { - throw new Error("Expected wrapped terminal line to be present."); - } - - const [match] = extractTerminalLinks(wrappedLine.text); - expect(match).toEqual({ - kind: "url", - text: "https://example.com/a/bc?x=1", - start: prefix.length, - end: firstSegment.length + secondSegment.length, - }); - if (!match) { - throw new Error("Expected wrapped URL match to be present."); - } - - const range = resolveWrappedTerminalLinkRange(wrappedLine, match); - - expect(range).toEqual({ - start: { x: prefix.length + 1, y: 2 }, - end: { x: secondSegment.length, y: 3 }, - }); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 2)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 3)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 4)).toBe(false); - }); -}); - describe("resolvePathLinkTarget", () => { it("resolves relative paths against cwd", () => { expect( diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 204d0a742a05..59e2082a7359 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -14,16 +14,6 @@ export interface TerminalLinkMatch { end: number; } -export interface TerminalLinkBufferPosition { - x: number; - y: number; -} - -export interface TerminalLinkBufferRange { - start: TerminalLinkBufferPosition; - end: TerminalLinkBufferPosition; -} - export interface TerminalBufferLineLike { readonly isWrapped?: boolean; translateToString(trimRight?: boolean): string; @@ -199,43 +189,6 @@ export function collectWrappedTerminalLinkLine( }; } -function resolveCharacterPosition( - segments: ReadonlyArray, - characterIndex: number, -): TerminalLinkBufferPosition { - for (const segment of segments) { - if (characterIndex < segment.endIndex) { - return { - x: characterIndex - segment.startIndex + 1, - y: segment.bufferLineNumber, - }; - } - } - - const lastSegment = segments[segments.length - 1]; - return { - x: Math.max(lastSegment?.text.length ?? 0, 1), - y: lastSegment?.bufferLineNumber ?? 1, - }; -} - -export function resolveWrappedTerminalLinkRange( - wrappedLine: WrappedTerminalLinkLine, - match: Pick, -): TerminalLinkBufferRange { - return { - start: resolveCharacterPosition(wrappedLine.segments, match.start), - end: resolveCharacterPosition(wrappedLine.segments, match.end - 1), - }; -} - -export function wrappedTerminalLinkRangeIntersectsBufferLine( - range: TerminalLinkBufferRange, - bufferLineNumber: number, -): boolean { - return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y; -} - export function isTerminalLinkActivation( event: Pick, platform = typeof navigator === "undefined" ? "" : navigator.platform, From c059d09b9b585d0c9562527e338e2f95cd2220f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:23:45 -0700 Subject: [PATCH 065/103] refactor(mobile): remove obsolete review list builder (#10039) --- .../src/features/review/reviewModel.test.ts | 81 ------------- .../mobile/src/features/review/reviewModel.ts | 110 ------------------ 2 files changed, 191 deletions(-) diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 3390afd9ff27..ee568085f7a2 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -8,7 +8,6 @@ import { } from "@t3tools/contracts"; import { - buildReviewListItems, buildReviewParsedDiff, buildReviewSectionItems, getDefaultReviewSectionId, @@ -271,84 +270,4 @@ describe("buildReviewParsedDiff", () => { actionLabel: "Load diff", }); }); - - it("flattens expanded file rows into virtualized review items", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/a.ts", - rows: [ - { - kind: "hunk", - id: "hunk-1", - header: "@@ -1,1 +1,2 @@", - context: null, - }, - { - kind: "line", - id: "line-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, - }, - ], - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "hunk", - fileId: file.id, - file, - row: file.rows[0], - }), - expect.objectContaining({ - kind: "line", - fileId: file.id, - file, - row: file.rows[1], - lineIndex: 0, - }), - ]); - }); - - it("keeps large diffs collapsed into a placeholder item until revealed", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/big.ts", - rows: Array.from({ length: 401 }, (_, index) => ({ - kind: "line" as const, - id: `line-${index}`, - change: "add" as const, - oldLineNumber: null, - newLineNumber: index + 1, - content: `const line${index} = ${index};`, - additionTokenIndex: index, - deletionTokenIndex: null, - comparison: null, - })), - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "file-suppressed", - fileId: file.id, - actionLabel: "Load diff", - }), - ]); - }); }); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d1..202157b837cc 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -58,45 +58,6 @@ export interface ReviewRenderableFile { readonly rows: ReadonlyArray; } -export interface ReviewFileHeaderListItem { - readonly kind: "file-header"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly expanded: boolean; -} - -export interface ReviewFileSuppressedListItem { - readonly kind: "file-suppressed"; - readonly id: string; - readonly fileId: string; - readonly message: string; - readonly actionLabel: string | null; -} - -export interface ReviewHunkListItem { - readonly kind: "hunk"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableHunkRow; -} - -export interface ReviewLineListItem { - readonly kind: "line"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableLineRow; - readonly lineIndex: number; -} - -export type ReviewListItem = - | ReviewFileHeaderListItem - | ReviewFileSuppressedListItem - | ReviewHunkListItem - | ReviewLineListItem; - export type ReviewFilePreviewState = | { readonly kind: "render"; @@ -316,77 +277,6 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil return { kind: "render" }; } -// The flattened review list item model is inspired by pierre/diffs' iterator-first -// virtualization architecture, adapted here for React Native virtualization. -// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs -// Reference files: -// - src/utils/iterateOverDiff.ts -// - src/components/VirtualizedFileDiff.ts -export function buildReviewListItems(input: { - readonly files: ReadonlyArray; - readonly expandedFileIds: ReadonlyArray; - readonly revealedLargeFileIds: ReadonlyArray; -}): ReadonlyArray { - const expandedFileIds = new Set(input.expandedFileIds); - const revealedLargeFileIds = new Set(input.revealedLargeFileIds); - const items: ReviewListItem[] = []; - - input.files.forEach((file) => { - const expanded = expandedFileIds.has(file.id); - items.push({ - kind: "file-header", - id: `${file.id}:header`, - fileId: file.id, - file, - expanded, - }); - - if (!expanded) { - return; - } - - const previewState = getReviewFilePreviewState(file); - if (previewState.kind === "suppressed") { - if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) { - items.push({ - kind: "file-suppressed", - id: `${file.id}:suppressed`, - fileId: file.id, - message: previewState.message, - actionLabel: previewState.actionLabel, - }); - return; - } - } - - let lineIndex = 0; - file.rows.forEach((row, rowIndex) => { - if (row.kind === "hunk") { - items.push({ - kind: "hunk", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - }); - return; - } - - items.push({ - kind: "line", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - lineIndex, - }); - lineIndex += 1; - }); - }); - - return items; -} - function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; } From a21c0d724684fe3e1775014fa0dad14e483c8315 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:25:07 -0700 Subject: [PATCH 066/103] test(server): remove duplicate VCS error constructor checks (#10042) --- apps/server/src/vcs/VcsProjectConfig.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 04f7fcffcda0..88f48e9e8afa 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -14,22 +14,6 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { - it("keeps operation context and the original cause on config errors", () => { - const cause = new Error("permission denied"); - const error = new VcsProjectConfig.VcsProjectConfigError({ - operation: "read", - cwd: "/repo/packages/app", - configPath: "/repo/.t3code/vcs.json", - cause, - }); - - assert.equal(error.operation, "read"); - assert.equal(error.cwd, "/repo/packages/app"); - assert.equal(error.configPath, "/repo/.t3code/vcs.json"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); - }); - it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { From 5e828bf3de1c86d85afdd91a02cc4ca1a38d4aef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:25:11 -0700 Subject: [PATCH 067/103] refactor(mobile): keep appearance calculations private (#10043) --- .../src/lib/appearancePreferences.test.ts | 19 ++++++------------- apps/mobile/src/lib/appearancePreferences.ts | 8 ++++---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index af09637b4e9f..417a66138d95 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_BASE_FONT_SIZE, - deriveCodeFontSize, - deriveTerminalFontSize, normalizeBaseFontSize, - normalizeCodeFontSize, - normalizeCodeWordBreak, resolveAppearance, resolveAppearancePreferences, resolveMarkdownFontSizes, @@ -48,10 +44,8 @@ describe("appearancePreferences", () => { expect(appearance.isCodeFontSizeCustom).toBe(false); const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); - expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); - expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); - expect(scaled.terminalFontSize).toBeGreaterThan(10); - expect(scaled.codeFontSize).toBeGreaterThan(11); + expect(scaled.terminalFontSize).toBe(14); + expect(scaled.codeFontSize).toBe(17); }); it("applies explicit overrides over derived values", () => { @@ -67,8 +61,8 @@ describe("appearancePreferences", () => { it("clamps base and code font sizes", () => { expect(normalizeBaseFontSize(4)).toBe(11); expect(normalizeBaseFontSize(30)).toBe(22); - expect(normalizeCodeFontSize(4)).toBe(8); - expect(normalizeCodeFontSize(30)).toBe(18); + expect(resolveAppearancePreferences({ codeFontSize: 4 }).codeFontSize).toBe(8); + expect(resolveAppearancePreferences({ codeFontSize: 30 }).codeFontSize).toBe(18); }); it("steps terminal font size within bounds", () => { @@ -92,9 +86,8 @@ describe("appearancePreferences", () => { }); }); - it("defaults code word break to false", () => { - expect(normalizeCodeWordBreak(undefined)).toBe(false); - expect(normalizeCodeWordBreak(true)).toBe(true); + it("keeps explicit code word break enabled", () => { + expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); it("returns the authored text scale at the 16pt default", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index b81c50056543..2ce6a8b5a367 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -75,7 +75,7 @@ export function normalizeBaseFontSize(value: number | null | undefined): number return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeFontSize(value: number | null | undefined): number { +function normalizeCodeFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CODE_FONT_SIZE; } @@ -83,18 +83,18 @@ export function normalizeCodeFontSize(value: number | null | undefined): number return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { +function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { return value === true; } /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ -export function deriveTerminalFontSize(baseFontSize: number): number { +function deriveTerminalFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); } /** Code/diff size derived from base: 12pt at base 16. */ -export function deriveCodeFontSize(baseFontSize: number): number { +function deriveCodeFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); } From 5a4287cd63aefef93c0fd15fe79f63a2f2657211 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:25:15 -0700 Subject: [PATCH 068/103] refactor(web): remove unused sidebar menu action (#10044) --- apps/web/src/components/ui/sidebar.test.tsx | 12 ------- apps/web/src/components/ui/sidebar.tsx | 35 +-------------------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index e2d29d607e13..784c5e087963 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -2,7 +2,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { - SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, @@ -89,17 +88,6 @@ describe("sidebar interactive cursors", () => { expect(html).not.toContain("cursor-pointer"); }); - it("uses a pointer cursor for menu actions", () => { - const html = renderToStaticMarkup( - - + - , - ); - - expect(html).toContain('data-slot="sidebar-menu-action"'); - expect(html).toContain("cursor-pointer"); - }); - it("uses a pointer cursor for submenu buttons", () => { const html = renderToStaticMarkup( }>Show more, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 624798e19f54..22cb4808fadd 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -800,7 +800,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", + "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", { defaultVariants: { size: "default", @@ -875,38 +875,6 @@ function SidebarMenuButton({ ); } -function SidebarMenuAction({ - className, - showOnHover = false, - render, - ...props -}: useRender.ComponentProps<"button"> & { - showOnHover?: boolean; -}) { - const defaultProps = { - className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:-inset-2 after:absolute md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground md:opacity-0", - className, - ), - "data-sidebar": "menu-action", - "data-slot": "sidebar-menu-action", - }; - - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">(defaultProps, props), - render, - }); -} - function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return (
Date: Sat, 5 Sep 2026 01:29:35 -0700 Subject: [PATCH 069/103] refactor(web): test live Ghostty link resolution directly (#10041) --- apps/web/src/terminal/ghostty/surface.test.ts | 30 +++++++++---------- apps/web/src/terminal/ghostty/surface.ts | 12 -------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index e9933cdb4b73..ee3240b41b08 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -23,8 +23,6 @@ import { terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, - terminalLinkAtColumn, - terminalLinkAtPosition, terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, @@ -433,7 +431,7 @@ describe("shouldBlinkTerminalCursor", () => { }); }); -describe("terminalLinkAtColumn", () => { +describe("terminalLinkAtPositionWithRange", () => { it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { const cells = [ cell("🙂"), @@ -450,9 +448,11 @@ describe("terminalLinkAtColumn", () => { wrapsToNext: false, }; - expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, 0)).toBeNull(); + expect(terminalLinkAtPositionWithRange([row], 0, 2)?.text).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([row], 0, cells.length - 1)?.text).toBe( + "https://t3.codes", + ); + expect(terminalLinkAtPositionWithRange([row], 0, 0)).toBeNull(); expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({ start: { x: 2, y: 0 }, end: { x: cells.length - 1, y: 0 }, @@ -473,10 +473,10 @@ describe("terminalLinkAtColumn", () => { row("C:\\repo\\file.ts", false), ]; - expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); - expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + expect(terminalLinkAtPositionWithRange(rows, 0, 8)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 1, 4)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 2, 2)?.text).toBe("~/project/file"); + expect(terminalLinkAtPositionWithRange(rows, 3, 4)?.text).toBe("C:\\repo\\file.ts"); expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({ text: "https://example.com/reference", range: { @@ -495,13 +495,13 @@ describe("terminalLinkAtColumn", () => { }); // The head of the wrapped line scrolled above the viewport. const headCut = [row("ple.com/missing", true), row("head", true)]; - expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull(); + expect(terminalLinkAtPositionWithRange(headCut, 0, 4)).toBeNull(); // The bottom row soft-wraps on below the viewport. const tailCut = [row("https://t3.codes", false, true)]; - expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange(tailCut, 0, 8)).toBeNull(); // A partial bottom row is provably complete and still resolves. const complete = [row("https://t3.codes", false), row("", false)]; - expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange(complete, 0, 8)?.text).toBe("https://t3.codes"); // A wide grapheme earlier in the row must not break truncation detection: // the soft-wrap flag decides, not string-length-versus-cell-count. const wideFull: GhosttyRow = { @@ -514,7 +514,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: true, }; - expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange([wideFull], 0, 8)).toBeNull(); // Unwritten trailing cells prove the bottom row is complete. const unwrittenTail: GhosttyRow = { cells: [ @@ -526,7 +526,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: false, }; - expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([unwrittenTail], 0, 8)?.text).toBe("https://t3.codes"); }); }); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 826dcd014053..29aaac6f6abd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -244,14 +244,6 @@ function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: n return offset; } -export function terminalLinkAtPosition( - rows: GhosttySnapshot["rowData"], - rowIndex: number, - column: number, -): string | null { - return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; -} - export interface TerminalLinkWithRange { readonly text: string; readonly range: GhosttyCellRange; @@ -325,10 +317,6 @@ export function terminalLinkAtPositionWithRange( return null; } -export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { - return terminalLinkAtPosition([row], 0, column); -} - export function isTerminalCopyShortcut( event: Pick, platform = navigator.platform, From df370c31d26afc72ace55263e746ba4386dada25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:29:40 -0700 Subject: [PATCH 070/103] test(server): exercise Codex prompts through public assembly (#10045) --- .../provider/CodexDeveloperInstructions.ts | 4 +-- .../Layers/CodexSessionRuntime.test.ts | 25 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2bb9647bd414..1c2439a9ad9a 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -22,7 +22,7 @@ Do not switch to global browser skills, Chrome, Node REPL browser automation, st const browserToolInstructions = (browserToolsAvailable: boolean): string => browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""; -export const codexPlanModeDeveloperInstructions = ( +const codexPlanModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Plan Mode (Conversational) @@ -155,7 +155,7 @@ If the user stays in Plan mode and asks for revisions after a prior \``; -export const codexDefaultModeDeveloperInstructions = ( +const codexDefaultModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Collaboration Mode: Default diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index fd4d66dd497b..0136c3fbf170 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,11 +9,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { - buildCodexDeveloperInstructions, - codexDefaultModeDeveloperInstructions, - codexPlanModeDeveloperInstructions, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -459,7 +455,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "high", }); - NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Collaboration Mode: Default/); NodeAssert.match(instructions, /T3 Code/); NodeAssert.match(instructions, /Codex harness/); NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); @@ -484,7 +480,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "medium", }); - NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Plan Mode/); NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); }); @@ -513,11 +509,11 @@ describe("buildCodexDeveloperInstructions", () => { }); describe("T3 browser developer instructions", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + it("prefers the product-native preview tools in both collaboration modes", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(true), - codexPlanModeDeveloperInstructions(true), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, true); NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); @@ -526,10 +522,8 @@ describe("T3 browser developer instructions", () => { }); it("omits the browser block entirely when the preview tools are not attached", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(false), - codexPlanModeDeveloperInstructions(false), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, false); NodeAssert.doesNotMatch(instructions, /preview_status/); NodeAssert.doesNotMatch(instructions, /preview_open/); NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); @@ -543,7 +537,6 @@ describe("T3 browser developer instructions", () => { }); it("tracks the turn's MCP configuration rather than defaulting to on", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); NodeAssert.doesNotMatch( buildCodexDeveloperInstructions("default", runtime, false), From 62e74cdd9a0aa4df5fc612d94193da9610129dc9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:29:44 -0700 Subject: [PATCH 071/103] refactor(shared): remove unused elapsed-time adapter (#10046) --- apps/web/src/session-logic.ts | 2 +- packages/shared/src/orchestrationTiming.test.ts | 8 +------- packages/shared/src/orchestrationTiming.ts | 10 ---------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 258d643efd5a..b6a6eb0e6342 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,7 +35,7 @@ import { type TurnDiffSummary, } from "./types"; -export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming"; +export { formatDuration } from "@t3tools/shared/orchestrationTiming"; export type WorkLogToolLifecycleStatus = | "inProgress" diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index 7703421d5c29..dab35ad3e08c 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDuration, formatElapsed } from "./orchestrationTiming.ts"; +import { formatDuration } from "./orchestrationTiming.ts"; describe("formatDuration", () => { it.each([ @@ -29,9 +29,3 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); - -describe("formatElapsed", () => { - it("formats a long run across midnight", () => { - expect(formatElapsed("2026-09-03T22:00:00Z", "2026-09-04T04:59:50Z")).toBe("6h 59m 50s"); - }); -}); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 956226e19a7d..98829ae052ad 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -28,16 +28,6 @@ export function formatDuration(durationMs: number): string { return parts.join(" "); } -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { - if (!endIso) return null; - const startedAt = Date.parse(startIso); - const endedAt = Date.parse(endIso); - if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { - return null; - } - return formatDuration(endedAt - startedAt); -} - export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, From 91ba05e870603b6bc0649e33973f73d86cbf58c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:30:07 -0700 Subject: [PATCH 072/103] refactor(web): remove unused preview thread reset helper (#10049) --- apps/web/src/previewStateStore.test.ts | 9 --------- apps/web/src/previewStateStore.ts | 7 ------- 2 files changed, 16 deletions(-) diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 321dd68c09aa..862fe46081e2 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -18,7 +18,6 @@ import { readThreadPreviewState, reconcilePreviewServerSessions, rememberPreviewUrl, - removePreviewThread, resetPreviewStateForTests, subscribeThreadPreviewState, setActivePreviewTab, @@ -609,12 +608,4 @@ describe("previewStateStore (single-tab)", () => { `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, ); }); - - it("removeThread strips the entry", () => { - const snapshot = makeSnapshot(); - applyPreviewServerSnapshot(ref, snapshot); - removePreviewThread(ref); - const state = readThreadPreviewState(ref); - expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); - }); }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index eb1052feeb87..90f8e27c3588 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -472,13 +472,6 @@ export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { })); } -export function removePreviewThread(ref: ScopedThreadRef): void { - const threadKey = scopedThreadKey(ref); - appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); - syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); - changedPreviewThreadKeys.delete(threadKey); -} - export function isPreviewSupportedInRuntime(): boolean { if (typeof window === "undefined") return false; return Boolean(window.desktopBridge?.preview); From 56a2f42b8ce27f5deefaa85a02ae9c1d3b61f309 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:30:18 -0700 Subject: [PATCH 073/103] refactor(desktop): remove test-only error predicates (#10047) --- apps/desktop/src/backend/DesktopServerExposure.test.ts | 3 --- apps/desktop/src/backend/DesktopServerExposure.ts | 2 -- apps/desktop/src/ipc/DesktopIpc.test.ts | 2 -- apps/desktop/src/ipc/DesktopIpc.ts | 1 - apps/desktop/src/preview/BrowserSession.test.ts | 6 ------ apps/desktop/src/preview/BrowserSession.ts | 2 -- apps/desktop/src/updates/DesktopUpdates.test.ts | 1 - apps/desktop/src/updates/DesktopUpdates.ts | 1 - 8 files changed, 18 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..4a8b516cb936 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -272,8 +272,6 @@ describe("DesktopServerExposure", () => { modeError, DesktopServerExposure.DesktopServerExposureModePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); assert.equal(modeError.mode, "network-accessible"); assert.strictEqual(modeError.cause, settingsFailure); assert.strictEqual(modeError.cause.cause, diskFailure); @@ -290,7 +288,6 @@ describe("DesktopServerExposure", () => { tailscaleError, DesktopServerExposure.DesktopTailscaleServePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); assert.equal(tailscaleError.enabled, true); assert.equal(tailscaleError.port, 8443); assert.strictEqual(tailscaleError.cause, settingsFailure); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..6c3cd55527eb 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -244,7 +244,6 @@ export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureModePersistenceError, ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; -export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, @@ -252,7 +251,6 @@ export const DesktopServerExposureError = Schema.Union([ DesktopTailscaleServePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; -export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index fc311877f829..5533831f9b55 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -41,7 +41,6 @@ describe("DesktopIpc", () => { const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "invoke"); assert.strictEqual(error.channel, invokeMethod.channel); assert.strictEqual(error.cause, cause); @@ -69,7 +68,6 @@ describe("DesktopIpc", () => { if (exit._tag === "Success") return; const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "sync"); assert.strictEqual(error.channel, syncMethod.channel); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index e948571cc628..643543d4ec33 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -55,7 +55,6 @@ export const DesktopIpcError = Schema.Union([ DesktopIpcUnregistrationError, ]); export type DesktopIpcError = typeof DesktopIpcError.Type; -export const isDesktopIpcError = Schema.is(DesktopIpcError); export interface DesktopIpcMethod { readonly channel: string; diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index ff22f3dd2272..aaf34c3578f9 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -172,8 +172,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-a"); assert.strictEqual(error.cause, platformCause); assert.strictEqual(error.cause.reason.cause, nativeCause); @@ -196,8 +194,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-b"); assert.equal(error.partition, partition); assert.strictEqual(error.cause, cause); @@ -270,7 +266,6 @@ describe("BrowserSession", () => { const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); assert.equal(storageError.partition, secondPartition); assert.strictEqual(storageError.cause, storageCause); assert.equal( @@ -287,7 +282,6 @@ describe("BrowserSession", () => { const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); assert.equal(cacheError.partition, firstPartition); assert.strictEqual(cacheError.cause, cacheCause); assert.equal( diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7f3c9ec5d7ac..7ff879852283 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -93,7 +93,6 @@ export const BrowserSessionGetSessionError = Schema.Union([ BrowserSessionCreationError, ]); export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; -export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, @@ -102,7 +101,6 @@ export const BrowserSessionError = Schema.Union([ BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; -export const isBrowserSessionError = Schema.is(BrowserSessionError); export class BrowserSession extends Context.Service< BrowserSession, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 1978337df3e7..509778521511 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -794,7 +794,6 @@ describe("DesktopUpdates", () => { const error = yield* updates.setChannel("nightly").pipe(Effect.flip); assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, settingsFailure); assert.strictEqual(error.cause.cause, diskFailure); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 20f005f2ab2d..344d135a1024 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -155,7 +155,6 @@ export const DesktopUpdateSetChannelError = Schema.Union([ DesktopUpdateChannelPersistenceError, ]); export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; -export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, From c2aff911c37f94558ab273ca3d44bb4854d01714 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:30:22 -0700 Subject: [PATCH 074/103] refactor(mobile): keep review reset hashing private (#10048) --- .../review/reviewDiffBridgeKeys.test.ts | 19 ++++++++----------- .../features/review/reviewDiffBridgeKeys.ts | 2 +- .../review/useNativeReviewDiffBridge.ts | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts index 9b39a51dc90f..406665def071 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; describe("native review diff bridge", () => { - it("builds stable reset keys from the rendered diff identity", () => { + it("changes reset keys when the rendered diff identity changes", () => { const input = { threadKey: "env:thread", sectionId: "turn:2", @@ -13,15 +13,12 @@ describe("native review diff bridge", () => { rowCount: 4, }; - expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); - expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( - buildNativeReviewTokensResetKey(input), - ); - expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); - }); + const resetKey = buildNativeReviewTokensResetKey(input); - it("includes diff length in the hash key to reduce accidental collisions", () => { - expect(hashReviewDiffKey("abc")).toMatch(/^3:/); - expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); + expect( + buildNativeReviewTokensResetKey({ ...input, diff: "diff --git a/b.ts b/b.ts" }), + ).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).not.toBe(resetKey); }); }); diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts index d04534003b37..6c7c1e545785 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts @@ -3,7 +3,7 @@ import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffH // Pure key-derivation helpers for the native review diff bridge. Kept free of // react-native / hook imports so they stay unit-testable in node. -export function hashReviewDiffKey(diff: string | null | undefined): string { +function hashReviewDiffKey(diff: string | null | undefined): string { if (!diff) { return "empty"; } diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index c6a656e012f7..1728da662686 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -8,7 +8,7 @@ import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighti import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +export { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; From a98dad77e15ef37d7fd6fc21e73434527a1fb21d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:31:18 -0700 Subject: [PATCH 075/103] test(web): remove AppRoot element order snapshot (#10052) --- apps/web/src/AppRoot.test.tsx | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 apps/web/src/AppRoot.test.tsx diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx deleted file mode 100644 index 791004b74fad..000000000000 --- a/apps/web/src/AppRoot.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; -import { RouterProvider } from "@tanstack/react-router"; -import { describe, expect, it } from "vite-plus/test"; - -import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; -import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; -import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; -import type { AppRouter } from "./router"; -import { AppRoot } from "./AppRoot"; - -describe("AppRoot", () => { - it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { - const root = AppRoot({ router: {} as AppRouter }); - - expect(root.type).toBe(AppAtomRegistryProvider); - const children = Children.toArray( - (root as ReactElement<{ readonly children: ReactNode }>).props.children, - ); - expect(children).toHaveLength(4); - expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); - expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); - }); -}); From 62e4ae400555e62a42d5cb401d744ba6dd0b401b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:38:51 -0700 Subject: [PATCH 076/103] refactor(codex): keep app-server client internals private (#10035) --- .../src/_internal/shared.ts | 2 +- packages/effect-codex-app-server/src/client.ts | 7 +------ packages/effect-codex-app-server/src/errors.ts | 17 ++++++++--------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 34155348abfa..8bcb59467d3d 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -5,7 +5,7 @@ import * as CodexError from "../errors.ts"; export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); -export const JsonRpcError = Schema.Struct({ +const JsonRpcError = Schema.Struct({ code: Schema.Number, message: Schema.String, data: Schema.optional(Schema.Unknown), diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index c0cb5b1dc23a..78d719626139 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -84,7 +84,7 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( +const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( stdio: Stdio.Stdio, options: CodexAppServerClientOptions = {}, terminationError?: Effect.Effect, @@ -250,11 +250,6 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make }); }); -export const layer = ( - stdio: Stdio.Stdio, - options: CodexAppServerClientOptions = {}, -): Layer.Layer => Layer.effect(CodexAppServerClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 3803b4d40659..f0bf470c251c 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -1,15 +1,15 @@ import * as Schema from "effect/Schema"; import type * as SchemaIssue from "effect/SchemaIssue"; -export const CodexAppServerRequestOperation = Schema.Literals([ +const CodexAppServerRequestOperation = Schema.Literals([ "decode-payload", "encode-payload", "handle-request", "receive-response", ]); -export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; +type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; -export const CodexAppServerSchemaIssueKind = Schema.Literals([ +const CodexAppServerSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -22,9 +22,9 @@ export const CodexAppServerSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; +type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; -export interface CodexAppServerSchemaIssueDiagnostics { +interface CodexAppServerSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; @@ -62,7 +62,7 @@ const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIs }; }; -export const CodexAppServerPayloadKind = Schema.Literals([ +const CodexAppServerPayloadKind = Schema.Literals([ "null", "array", "string", @@ -74,7 +74,7 @@ export const CodexAppServerPayloadKind = Schema.Literals([ "function", "undefined", ]); -export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; +type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { if (payload === null) return "null"; @@ -84,8 +84,7 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; -export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); -export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; +const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); export interface CodexAppServerRequestDiagnostics { readonly method?: string; From 4631000f5a7666c88402ce11a9ebb8cdef7a7dad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:38:51 -0700 Subject: [PATCH 077/103] ci: reject unused Codex client exports with Knip (#10036) --- docs/operations/development.md | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index de5c6c64e3ab..39686807d6dd 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -72,7 +72,8 @@ Windows investigation while that suite is not a required gate. ### Unused code `vp run knip:check` checks unused files and dependencies across the repo, then -unused exports and types in `packages/tailscale`. CI enforces both checks. +unused exports and types in `packages/tailscale` and `packages/effect-codex-app-server`. +CI enforces both checks. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. The full export audit still has findings and is not a repo-wide CI gate. Extend the diff --git a/package.json b/package.json index 2882a1f9ab7e..4fc0dafeb0e9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --exports --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --workspace packages/effect-codex-app-server --exports --no-config-hints", "knip:production": "knip --production", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From 45f5a5ffb3257d510f249b14c36a514beff2868a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:47:36 -0700 Subject: [PATCH 078/103] refactor(server): simplify native telemetry error internals (#10057) --- .../NativeTelemetryClient.test.ts | 42 ------------------- .../NativeTelemetryClient.ts | 10 ++--- 2 files changed, 3 insertions(+), 49 deletions(-) diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 8a595bc8b480..7d365d0c0bac 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -1,6 +1,5 @@ import type { HostPowerSnapshot } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -9,12 +8,9 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import { - NativeTelemetryRequestTimedOut, - NativeTelemetryStreamClosed, canCommandNativeTelemetrySidecar, canRequestNativeTelemetryRetry, commitCollectionControlUpdate, - nativeTelemetrySupervisorFailureMessage, retainRecentNativeTelemetryFailures, resolveNativeSampleIntervalMs, synchronizeCollectionControlOnStart, @@ -82,44 +78,6 @@ describe("canCommandNativeTelemetrySidecar", () => { }); }); -describe("NativeTelemetryRequestTimedOut", () => { - it("models history and sample request deadlines without a fabricated cause", () => { - const historyTimeout = new NativeTelemetryRequestTimedOut({ - operation: "readHistory", - timeoutMs: 15_000, - }); - const sampleTimeout = new NativeTelemetryRequestTimedOut({ - operation: "sampleNow", - timeoutMs: 5_000, - }); - - expect(historyTimeout.message).toBe( - "Resource monitor 'readHistory' request timed out after 15000ms.", - ); - expect(sampleTimeout.message).toBe( - "Resource monitor 'sampleNow' request timed out after 5000ms.", - ); - expect("cause" in historyTimeout).toBe(false); - expect("cause" in sampleTimeout).toBe(false); - }); -}); - -describe("native telemetry supervisor failures", () => { - it("distinguishes a closed event stream from a process exit", () => { - expect(new NativeTelemetryStreamClosed().message).toBe( - "Resource monitor event stream closed unexpectedly.", - ); - }); - - it("keeps defect details out of the caller-visible health message", () => { - const secret = "credential=do-not-expose"; - const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret))); - - expect(message).toBe("Resource monitor supervisor stopped unexpectedly."); - expect(message).not.toContain(secret); - }); -}); - describe("retainRecentNativeTelemetryFailures", () => { it("expires old failures so an isolated crash restarts from the initial backoff", () => { expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 232079d9dc9b..4af8f1b762d5 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -73,7 +73,7 @@ export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass()( +class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass()( "NativeTelemetryRequestTimedOut", { operation: Schema.Literals(["readHistory", "sampleNow"]), @@ -131,7 +131,7 @@ export class NativeTelemetryExited extends Schema.TaggedErrorClass()( +class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass()( "NativeTelemetryStreamClosed", {}, ) { @@ -340,10 +340,6 @@ function errorMessage(error: NativeTelemetryClientError): string { return error.message; } -export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause): string { - return "Resource monitor supervisor stopped unexpectedly."; -} - export function canRequestNativeTelemetryRetry( status: ResourceTelemetrySourceStatus, hasHandle: boolean, @@ -734,7 +730,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu ...current, status: "unavailable" as const, hello: Option.none(), - lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)), + lastError: Option.some("Resource monitor supervisor stopped unexpectedly."), })).pipe( Effect.andThen(publishHealth), Effect.andThen( From 5b7f6bcfbcd5f150a67f323e0ec5aeb70b3c4009 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:49:06 -0700 Subject: [PATCH 079/103] refactor(mobile): remove write-only terminal font cache (#10058) --- .../appearance/AppearancePreferencesProvider.tsx | 4 +--- .../src/features/terminal/terminalUiState.test.ts | 10 ---------- .../src/features/terminal/terminalUiState.ts | 14 -------------- 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 79d67ebaa7c2..f161e654788b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -37,7 +37,6 @@ import { getMobileUniwindThemeName, type MobileThemeRuntimeState, } from "../../../lib/mobileThemeRuntime"; -import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ @@ -143,8 +142,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN useLayoutEffect(() => { selectedThemeIdsRef.current = themeIds; syncThemeRuntime(runtimeState); - cacheTerminalFontSize(appearance.terminalFontSize); - }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]); + }, [runtimeState, syncThemeRuntime, themeIds]); const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { diff --git a/apps/mobile/src/features/terminal/terminalUiState.test.ts b/apps/mobile/src/features/terminal/terminalUiState.test.ts index 0bb3c1395915..6879fdfdbb20 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.test.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { - cacheTerminalFontSize, cacheTerminalGridSize, - getCachedTerminalFontSize, getCachedTerminalGridSize, resetTerminalUiStateCaches, } from "./terminalUiState"; @@ -14,14 +12,6 @@ describe("terminalUiState", () => { resetTerminalUiStateCaches(); }); - it("caches terminal font size using the shared normalization rules", () => { - expect(getCachedTerminalFontSize()).toBeNull(); - expect(cacheTerminalFontSize(8.5)).toBe(8.5); - expect(getCachedTerminalFontSize()).toBe(8.5); - expect(cacheTerminalFontSize(100)).toBe(14); - expect(getCachedTerminalFontSize()).toBe(14); - }); - it("stores terminal grid sizes per terminal target", () => { const primaryTarget = { environmentId: EnvironmentId.make("env-1"), diff --git a/apps/mobile/src/features/terminal/terminalUiState.ts b/apps/mobile/src/features/terminal/terminalUiState.ts index 2cac0bf52b9e..84274430e8a1 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.ts @@ -1,7 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences"; - export interface TerminalGridSize { readonly cols: number; readonly rows: number; @@ -14,22 +12,11 @@ export interface TerminalUiStateTarget { } const terminalGridSizeCache = new Map(); -let cachedTerminalFontSize: number | null = null; function terminalUiStateKey(target: TerminalUiStateTarget): string { return `${target.environmentId}:${target.threadId}:${target.terminalId}`; } -export function getCachedTerminalFontSize(): number | null { - return cachedTerminalFontSize; -} - -export function cacheTerminalFontSize(value: number | null | undefined): number { - const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE); - cachedTerminalFontSize = normalized; - return normalized; -} - export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null { return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null; } @@ -47,6 +34,5 @@ export function cacheTerminalGridSize( } export function resetTerminalUiStateCaches() { - cachedTerminalFontSize = null; terminalGridSizeCache.clear(); } From 80f775a42c448290c102e572fc889e4e4a1dd546 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:49:44 -0700 Subject: [PATCH 080/103] refactor(web): remove obsolete HSL theme generator (#10061) --- apps/web/src/themePalette.test.ts | 66 +++----- apps/web/src/themePalette.ts | 247 ------------------------------ 2 files changed, 19 insertions(+), 294 deletions(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index a76885a862ee..e1d9bfb74cab 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -34,7 +34,6 @@ import { OCEAN_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, - createManagedThemeColors, createVividThemeColors, getDefaultThemeColors, themeColorToHex, @@ -91,50 +90,6 @@ describe("theme files", () => { } }); - it("derives a readable palette from extreme simple-editor colors", () => { - const light = createManagedThemeColors("light", "#111827", "#ffff00"); - const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); - const darkDefaults = getDefaultThemeColors("dark"); - - expect(asHex(light.canvas)).not.toBe("#111827"); - expect(asHex(dark.canvas)).not.toBe("#ffffff"); - expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeLessThan(5.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeLessThan(5.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeCloseTo(4.705, 1); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeCloseTo(5.082, 1); - expect(light.secondaryLabel).toBe(light.textMuted); - expect(dark.secondaryLabel).toBe(dark.textMuted); - expect(contrastRatio(light.accentForeground, light.accent)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accentForeground, dark.accent)).toBeGreaterThanOrEqual(4.5); - // Status colors fall back to T3 Code's standard red and amber rather than - // the flagship palette's, so no generated theme inherits a brand tint. - const channels = (value: string) => - [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ - number, - number, - number, - ]; - for (const colors of [light, dark]) { - const [errorRed, errorGreen, errorBlue] = channels(colors.error); - // Red leads by a wide margin; the old default was a pink whose blue sat - // close behind its red. - expect(errorRed).toBeGreaterThan(errorGreen * 2); - expect(errorRed).toBeGreaterThan(errorBlue * 2); - expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); - expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( - 4.5, - ); - const [warnRed, warnGreen, warnBlue] = channels(colors.warning); - expect(warnRed).toBeGreaterThan(warnBlue); - expect(warnGreen).toBeGreaterThan(warnBlue); - } - expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); - }); - it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", @@ -158,6 +113,12 @@ describe("theme files", () => { ["light", "#111827", "#8ab4f8"], ["dark", "#f5ecf5", "#a84370"], ]; + const channels = (value: string) => + [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ + number, + number, + number, + ]; for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. @@ -193,6 +154,17 @@ describe("theme files", () => { expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. expect(asHex(colors.update)).toBe(accent); + // Semantic statuses stay red and amber instead of inheriting a brand tint. + const [errorRed, errorGreen, errorBlue] = channels(colors.error); + expect(errorRed).toBeGreaterThan(errorGreen * 2); + expect(errorRed).toBeGreaterThan(errorBlue * 2); + expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); + expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( + 4.5, + ); + const [warnRed, warnGreen, warnBlue] = channels(colors.warning); + expect(warnRed).toBeGreaterThan(warnBlue); + expect(warnGreen).toBeGreaterThan(warnBlue); } }); @@ -202,8 +174,8 @@ describe("theme files", () => { const inverted = [ createVividThemeColors("light", "#111827", "#8ab4f8"), createVividThemeColors("dark", "#f5ecf5", "#a84370"), - createManagedThemeColors("light", "#0d1117", "#69b1ff", { exactSeeds: true }), - createManagedThemeColors("dark", "#fdfdfd", "#c2571b", { exactSeeds: true }), + createVividThemeColors("light", "#0d1117", "#69b1ff"), + createVividThemeColors("dark", "#fdfdfd", "#c2571b"), ]; for (const colors of inverted) { expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index f67eb943e3fb..f4b5c83df395 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -475,12 +475,6 @@ type ThemeRgbColor = { b: number; }; -type ThemeHslColor = { - h: number; - s: number; - l: number; -}; - type ThemeOklch = { L: number; C: number; h: number }; type ParsedThemeColor = { color: ThemeOklch; alpha: number }; @@ -601,48 +595,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { }; } -function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor { - const red = color.r / 255; - const green = color.g / 255; - const blue = color.b / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const delta = max - min; - const lightness = (max + min) / 2; - - if (delta === 0) return { h: 0, s: 0, l: lightness }; - - const saturation = delta / (1 - Math.abs(2 * lightness - 1)); - let hue = 0; - if (max === red) hue = ((green - blue) / delta) % 6; - else if (max === green) hue = (blue - red) / delta + 2; - else hue = (red - green) / delta + 4; - - return { h: (hue * 60 + 360) % 360, s: saturation, l: lightness }; -} - -function themeHslToRgb(color: ThemeHslColor): ThemeRgbColor { - const hue = ((color.h % 360) + 360) % 360; - const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s; - const hueSector = hue / 60; - const secondary = chroma * (1 - Math.abs((hueSector % 2) - 1)); - const match = color.l - chroma / 2; - const [red, green, blue] = - hueSector < 1 - ? [chroma, secondary, 0] - : hueSector < 2 - ? [secondary, chroma, 0] - : hueSector < 3 - ? [0, chroma, secondary] - : hueSector < 4 - ? [0, secondary, chroma] - : hueSector < 5 - ? [secondary, 0, chroma] - : [chroma, 0, secondary]; - - return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255 }; -} - function mixThemeRgbColors( base: ThemeRgbColor, overlay: ThemeRgbColor, @@ -1046,205 +998,6 @@ function standardMutedThemeText( return readableThemeText(background, foreground, 1, target); } -function managedThemeBackground(value: string, appearance: ThemeAppearance): ThemeRgbColor { - const selected = parseThemeRgbColor( - value, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ); - const hsl = themeRgbToHsl(selected); - return themeHslToRgb({ - h: hsl.h, - // A background tint should support the selected mode, not turn the whole - // app into a high-saturation surface. - s: Math.min(hsl.s, appearance === "dark" ? 0.3 : 0.2), - l: - appearance === "dark" - ? Math.min(0.13, Math.max(0.07, hsl.l)) - : Math.min(0.985, Math.max(0.94, hsl.l)), - }); -} - -function managedThemeAccent( - value: string, - appearance: ThemeAppearance, - background: ThemeRgbColor, -): ThemeRgbColor { - const selected = parseThemeRgbColor(value, { r: 168, g: 67, b: 112 }); - const hsl = themeRgbToHsl(selected); - const preferredLightness = - appearance === "dark" - ? Math.min(0.72, Math.max(0.42, hsl.l)) - : Math.min(0.58, Math.max(0.35, hsl.l)); - const lightnessRange: readonly [number, number] = - appearance === "dark" ? [0.42, 0.82] : [0.22, 0.58]; - const saturation = Math.min(hsl.s, 0.82); - const candidates = Array.from({ length: 61 }, (_, index) => { - const lightness = - lightnessRange[0] + ((lightnessRange[1] - lightnessRange[0]) * index) / (61 - 1); - const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness }); - return { color, lightness, contrast: themeContrastRatio(color, background) }; - }); - // Leave a little room for browser color conversion at render time. - const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7); - const pool = readableCandidates.length > 0 ? readableCandidates : candidates; - - return pool.reduce((best, candidate) => { - const distance = Math.abs(candidate.lightness - preferredLightness); - const bestDistance = Math.abs(best.lightness - preferredLightness); - return distance < bestDistance || - (distance === bestDistance && candidate.contrast > best.contrast) - ? candidate - : best; - }).color; -} - -/** - * Creates the guided palette used by the basic theme editor. The two user - * colors control the mood, while dependent roles are generated together so - * text, surfaces, message actions, code, and terminal UI stay coherent. - */ -export function createManagedThemeColors( - appearance: ThemeAppearance, - backgroundValue: string, - accentValue: string, - options?: { - /** Use the seeds exactly as given instead of nudging them into the - * readability envelope. Derived foregrounds still adapt for contrast. */ - exactSeeds?: boolean; - }, -): ThemeColors { - const defaults = getDefaultThemeColors(appearance); - const canvas = options?.exactSeeds - ? parseThemeRgbColor( - backgroundValue, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ) - : managedThemeBackground(backgroundValue, appearance); - const accent = options?.exactSeeds - ? parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 }) - : managedThemeAccent(accentValue, appearance, canvas); - const text = readableThemeForeground(canvas); - const textMuted = standardMutedThemeText(canvas, text); - // The top bar is part of the main panel, not a separate chrome layer: it - // shares the canvas, and its controls sit on the panel's own surfaces. - const chrome = canvas; - const sidebar = mixThemeRgbColors(canvas, accent, 0.08); - const surfaceRaised = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.12 : 0.035); - const surfaceOverlay = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.18 : 0.06); - const secondary = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.2 : 0.08); - const muted = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.13 : 0.06); - const mutedForeground = readableThemeText(muted, text, 1, 4.6); - const placeholder = readableThemeText(surfaceRaised, text, 1, 4.6); - const accentSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14); - const messageSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.36 : 0.18); - const toolbarControl = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.2 : 0.08); - const toolbarBorder = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.35 : 0.14); - const accentForeground = readableThemeForeground(accent); - // Code and terminal are large surfaces: they keep the canvas hue instead of - // drifting toward the foreground grey. Code sits just above the canvas — - // a whisper of the text tint — and the terminal sits on the canvas itself. - const codeBackground = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.06 : 0.025); - const terminalBackground = canvas; - const messageActionHover = mixThemeRgbColors( - accent, - accentForeground === THEME_LIGHT_FOREGROUND || accentForeground === THEME_WHITE_FOREGROUND - ? THEME_BLACK_FOREGROUND - : THEME_WHITE_FOREGROUND, - 0.12, - ); - - // The update family follows the accent instead of inheriting the default - // palette's brand color, so generated themes carry their own identity in - // update pills and banners. Error and warning stay semantic defaults. - const updateSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.32 : 0.16); - const updateForeground = mixThemeRgbColors( - accent, - appearance === "dark" ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND, - 0.35, - ); - - return { - ...defaults, - ...standardStatusColors(canvas), - update: themeRgbToThemeColor(accent), - updateForeground: themeRgbToThemeColor(updateForeground), - updateSurface: themeRgbToThemeColor(updateSurface), - canvas: themeRgbToThemeColor(canvas), - chrome: themeRgbToThemeColor(chrome), - toolbar: themeRgbToThemeColor(chrome), - toolbarForeground: themeRgbToThemeColor(text), - toolbarBorder: themeRgbToThemeColor(toolbarBorder), - toolbarControl: themeRgbToThemeColor(toolbarControl), - toolbarControlForeground: themeRgbToThemeColor(text), - toolbarControlHover: themeRgbToThemeColor(accentSurface), - surface: themeRgbToThemeColor(canvas), - surfaceRaised: themeRgbToThemeColor(surfaceRaised), - surfaceOverlay: themeRgbToThemeColor(surfaceOverlay), - text: themeRgbToThemeColor(text), - textMuted: themeRgbToThemeColor(textMuted), - // Borders blend through the accent before lightening so control chrome - // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1) - // instead of flattening to grey. - border: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1), - text, - 0.1, - ), - ), - input: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14), - text, - appearance === "dark" ? 0.14 : 0.13, - ), - ), - focus: themeRgbToThemeColor(accent), - accent: themeRgbToThemeColor(accent), - accentForeground: themeRgbToThemeColor(accentForeground), - secondary: themeRgbToThemeColor(secondary), - secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)), - muted: themeRgbToThemeColor(muted), - mutedForeground: themeRgbToThemeColor(mutedForeground), - placeholder: themeRgbToThemeColor(placeholder), - secondaryLabel: themeRgbToThemeColor(textMuted), - iconMuted: themeRgbToThemeColor(textMuted), - accentSurface: themeRgbToThemeColor(accentSurface), - accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)), - messageSurface: themeRgbToThemeColor(messageSurface), - messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)), - messageAction: themeRgbToThemeColor(accent), - messageActionForeground: themeRgbToThemeColor(accentForeground), - messageActionHover: themeRgbToThemeColor(messageActionHover), - codeBackground: themeRgbToThemeColor(codeBackground), - codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)), - sidebar: themeRgbToThemeColor(sidebar), - sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)), - sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)), - sidebarControlSurface: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08), - ), - sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)), - sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)), - sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)), - sidebarBorder: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12), - ), - terminalBackground: themeRgbToThemeColor(terminalBackground), - terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)), - terminalCursor: themeRgbToThemeColor(accent), - terminalSelection: themeRgbToThemeColor( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18), - ), - terminalScrollbar: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22), - ), - terminalScrollbarHover: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32), - ), - }; -} - /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; From d524eb96936090bd2b8a96449784155ad4af9591 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:50:46 -0700 Subject: [PATCH 081/103] refactor(mobile): remove obsolete native diff token stream (#10062) --- .../diffs/nativeReviewDiffHighlighter.test.ts | 23 +---- .../diffs/nativeReviewDiffHighlighter.ts | 84 ------------------- 2 files changed, 1 insertion(+), 106 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index e7e75a4faa1a..24b18edd5db3 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -2,11 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; -import { - highlightNativeReviewDiffVisibleRows, - streamNativeReviewDiffTokens, - type NativeReviewDiffTokenChunk, -} from "./nativeReviewDiffHighlighter"; +import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; const tokenization = vi.hoisted(() => ({ calls: [] as string[], @@ -340,21 +336,4 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.rowCount).toBe(0); expect(result.tokensByRowId).toEqual({}); }); - - it("applies the same long-line guard to streamed token chunks", async () => { - const content = "x".repeat(10_000); - const chunks: NativeReviewDiffTokenChunk[] = []; - - await streamNativeReviewDiffTokens({ - rows: [line(1, content)], - files: [TYPESCRIPT_FILE], - scheme: "dark", - engine, - onChunk: (chunk) => chunks.push(chunk), - }); - - expect(chunks).toHaveLength(1); - expect(chunks[0]?.tokensByRowId["line-1"]).toEqual([{ content, color: null, fontStyle: null }]); - expect(tokenization.calls).toHaveLength(0); - }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 0ea8c100dc11..e924ff1aa645 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -65,26 +65,6 @@ interface IndexedNativeReviewDiffLineRow { readonly rowIndex: number; } -export interface NativeReviewDiffTokenChunk { - readonly chunkIndex: number; - readonly fileId: string; - readonly filePath: string; - readonly language: NativeReviewDiffLanguage; - readonly lineCount: number; - readonly durationMs: number; - readonly tokensByRowId: Record>; -} - -export interface StreamNativeReviewDiffTokenInput { - readonly rows: ReadonlyArray; - readonly files: ReadonlyArray; - readonly scheme: NativeReviewDiffHighlightScheme; - readonly engine?: NativeReviewDiffHighlightEngine; - readonly chunkSize?: number; - readonly signal?: AbortSignal; - readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void; -} - export interface HighlightNativeReviewDiffVisibleRowsInput { readonly rows: ReadonlyArray; readonly files: ReadonlyArray; @@ -98,7 +78,6 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly signal?: AbortSignal; } -const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000; @@ -423,20 +402,6 @@ function canShareGrammarContext( ); } -function groupLineRowsByFileId(rows: ReadonlyArray) { - const rowsByFileId = new Map(); - for (const row of rows) { - if (!isHighlightableLineRow(row)) { - continue; - } - - const fileRows = rowsByFileId.get(row.fileId) ?? []; - fileRows.push(row); - rowsByFileId.set(row.fileId, fileRows); - } - return rowsByFileId; -} - function createFileMap(files: ReadonlyArray) { return new Map(files.map((file) => [file.id, file])); } @@ -561,52 +526,3 @@ export async function highlightNativeReviewDiffVisibleRows( durationMs: Math.round(performance.now() - startedAt), }; } - -export async function streamNativeReviewDiffTokens( - input: StreamNativeReviewDiffTokenInput, -): Promise { - const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); - const rowsByFileId = groupLineRowsByFileId(input.rows); - const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; - const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE; - let chunkIndex = 0; - - for (const file of input.files) { - const fileRows = rowsByFileId.get(file.id) ?? []; - for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) { - if (input.signal?.aborted) { - return highlighter.engine; - } - - const startedAt = performance.now(); - const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); - const code = chunkRows.map((row) => row.content).join("\n"); - const tokenLines = await highlighter.tokenize(code, { - lang: file.language, - theme, - signal: input.signal, - }); - if (input.signal?.aborted) return highlighter.engine; - const tokensByRowId: Record> = {}; - - chunkRows.forEach((row, rowIndex) => { - tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); - }); - - input.onChunk({ - chunkIndex, - fileId: file.id, - filePath: file.path, - language: file.language, - lineCount: chunkRows.length, - durationMs: Math.round(performance.now() - startedAt), - tokensByRowId, - }); - - chunkIndex += 1; - await waitForNextFrame(); - } - } - - return highlighter.engine; -} From bc24d98d1630104722abe6a4d839365d24e2aa27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:51:43 -0700 Subject: [PATCH 082/103] test(server): remove title prompt editorial snapshots (#10063) --- .../TextGenerationPrompts.test.ts | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 253d88779827..05f45b4cb3b0 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message and the title guidance rules", () => { + it("includes the user message without absent attachment metadata", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); @@ -154,18 +154,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("User message:"); expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); expect(result.prompt).not.toContain("Attachment metadata:"); - expect(result.prompt).toContain( - "Generate a title that will help the user recognize this T3 Code thread weeks later.", - ); - expect(result.prompt).toContain( - "Title the subject and outcome. Discard incidental instructions.", - ); - expect(result.prompt).toContain( - "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", - ); - expect(result.prompt).not.toContain( - "Title should summarize the user's request, not restate it verbatim.", - ); }); it("includes attachment metadata when attachments are provided", () => { @@ -188,24 +176,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("67890 bytes"); }); - it.each([ - { mode: "initial", previousTitle: undefined }, - { mode: "regeneration", previousTitle: "Open Projects in Desktop App" }, - ])( - "tells the $mode prompt not to title linked PRs from local git history", - ({ previousTitle }) => { - const result = buildThreadTitlePrompt({ - message: "$takeover https://github.com/pingdotgg/t3code/pull/8588", - ...(previousTitle === undefined ? {} : { previousTitle }), - }); - - expect(result.prompt).toContain( - "Local git history is not evidence of what a linked PR or issue is about.", - ); - expect(result.prompt).toContain('such as "Take Over PR 8588"'); - }, - ); - it("regenerates from recent thread contents and identifies the previous title", () => { const result = buildThreadTitlePrompt({ message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, @@ -216,15 +186,6 @@ describe("buildThreadTitlePrompt", () => { "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); - expect(result.prompt).toContain( - "Read the USER messages first. Identify the latest explicit durable goal.", - ); - expect(result.prompt).toContain( - "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", - ); - expect(result.prompt).toContain( - 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', - ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); }); From 160e337a5c521bcaae94b386d22226f32242fdd0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:52:24 -0700 Subject: [PATCH 083/103] test(server): remove repeated runtime prompt interpolation cases (#10059) --- apps/server/src/provider/RuntimeInstructions.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 350d8c73c150..320aa332c937 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -2,17 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { - it.each(["Codex", "Claude Code", "Cursor", "Grok", "OpenCode", "Antigravity"])( - "identifies the %s harness and describes media embedding", - (harness) => { - const instructions = buildRuntimeInstructions({ harness }); - expect(instructions).toContain(`running in T3 Code through the ${harness} harness.`); - expect(instructions).toContain("embed images and videos"); - expect(instructions).toContain("Markdown with absolute file paths"); - expect(instructions).not.toContain("undefined"); - }, - ); - it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ From 43700b83e73fca3af7b63769876c409b5c6f5a3f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:53:19 -0700 Subject: [PATCH 084/103] refactor(web): observe preview tests through the live registry (#10064) --- apps/web/src/previewStateStore.test.ts | 4 ++-- apps/web/src/previewStateStore.ts | 13 ------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 862fe46081e2..f9956f9c02c8 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -19,10 +19,10 @@ import { reconcilePreviewServerSessions, rememberPreviewUrl, resetPreviewStateForTests, - subscribeThreadPreviewState, setActivePreviewTab, updatePreviewServerSnapshot, } from "./previewStateStore"; +import { appAtomRegistry } from "./rpc/atomRegistry"; const environmentId = "env-1" as EnvironmentId; const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); @@ -352,7 +352,7 @@ describe("previewStateStore (single-tab)", () => { }, }; let updateCount = 0; - const unsubscribe = subscribeThreadPreviewState(ref, () => { + const unsubscribe = appAtomRegistry.subscribe(previewStateAtom(scopedThreadKey(ref)), () => { updateCount += 1; }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 90f8e27c3588..1e7ec3706618 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -173,19 +173,6 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); } -export function subscribeThreadPreviewState( - ref: ScopedThreadRef, - listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, -): () => void { - const atom = previewStateAtom(scopedThreadKey(ref)); - let previous = appAtomRegistry.get(atom); - return appAtomRegistry.subscribe(atom, (state) => { - const prior = previous; - previous = state; - listener(state, prior); - }); -} - export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; From fedf84ac7922e11be0971ac50817d7c4199b8e89 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:53:31 -0700 Subject: [PATCH 085/103] test(server): remove keybinding default assignment snapshot (#10065) --- apps/server/src/keybindings.test.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 160755a8f9b2..ec7070809435 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,33 +188,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("ships configurable thread navigation defaults", () => - Effect.sync(() => { - const defaultsByCommand = new Map( - Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), - ); - - assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); - assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); - assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); - assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); - assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); - assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); - assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); - assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); - assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); - assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); - assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); - assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); - assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); - assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); - assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); - assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); - }), - ); - it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; From 4db2c542a8c5e6b6756fac08615e30b220b71a2f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:53:42 -0700 Subject: [PATCH 086/103] refactor(mobile): remove obsolete whole-file review highlighters (#10067) --- .../review/shikiReviewHighlighter.test.ts | 206 ++++----- .../features/review/shikiReviewHighlighter.ts | 408 +----------------- 2 files changed, 86 insertions(+), 528 deletions(-) diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index be723040152a..6d36171d2711 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,131 +1,60 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; +import type { ReviewRenderableLineRow } from "./reviewModel"; +import { + highlightCodeSnippet, + highlightReviewSelectedLines, + highlightSourceFile, +} from "./shikiReviewHighlighter"; -function makeRenderableFile( - input: Partial & Pick, -): ReviewRenderableFile { - return { - id: input.path, - cacheKey: input.path, - previousPath: null, - changeType: "new", - additions: 0, - deletions: 0, - languageHint: null, - additionLines: [], - deletionLines: [], - rows: [], - ...input, - }; -} - -describe("highlightReviewFile", () => { - it("preserves one highlighted token row per diff line even without trailing newlines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example.txt", - additionLines: [ - 'const items = ["a"];', - 'expect(items).toEqual(["a"]);', - "const next = items.map((item) => item.toUpperCase());", - 'expect(next).toContain("A");', - ], +describe("highlightSourceFile", () => { + it("preserves one highlighted token row per source line without trailing newlines", async () => { + const lines = [ + 'const items = ["a"];', + 'expect(items).toEqual(["a"]);', + "const next = items.map((item) => item.toUpperCase());", + 'expect(next).toContain("A");', + ]; + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example.ts", + contents: lines.join("\n"), + theme: "light", }); - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(file.additionLines.length); - expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( - file.additionLines[0], - ); - expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( - file.additionLines[1], + expect(highlighted.map((tokens) => tokens.map((token) => token.content).join(""))).toEqual( + lines, ); - expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( - file.additionLines[2], - ); - expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( - file.additionLines[3], - ); - }); - - it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example-inline-diff.txt", - additionLines: ["const after = 2;"], - deletionLines: ["const before = 1;"], - rows: [ - { - kind: "line", - id: "delete-1", - change: "delete", - oldLineNumber: 1, - newLineNumber: null, - content: "const before = 1;", - additionTokenIndex: null, - deletionTokenIndex: 0, - comparison: { change: "add", tokenIndex: 0 }, - }, - { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: { change: "delete", tokenIndex: 0 }, - }, - ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); - expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); }); it("falls back to plain tokens for very long lines", async () => { const longLine = `const value = "${"a".repeat(1_100)}";`; - const file = makeRenderableFile({ - path: "apps/mobile/src/example-long-line.txt", - additionLines: [longLine], - rows: [ + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example-long-line.ts", + contents: longLine, + theme: "light", + }); + + expect(highlighted).toEqual([ + [ { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, content: longLine, - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, + color: null, + fontStyle: null, }, ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(1); - expect(highlighted.additionLines[0]).toEqual([ - { - content: longLine, - color: null, - fontStyle: null, - }, ]); }); -}); -describe("highlightCodeSnippet", () => { - it("resolves language aliases and returns syntax-colored tokens", async () => { + it("initializes source and snippet highlighting without a warmup", async () => { + vi.resetModules(); + const highlighter = await import("./shikiReviewHighlighter"); const source = "const answer: number = 42;"; - const highlighted = await highlightCodeSnippet({ - code: source, - language: "ts", + + const highlighted = await highlighter.highlightSourceFile({ + path: "example.ts", + contents: source, theme: "dark", }); @@ -136,18 +65,56 @@ describe("highlightCodeSnippet", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + expect( + await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), + ).toEqual(highlighted); }); }); -describe("highlightSourceFile", () => { - it("initializes source and snippet highlighting without a warmup", async () => { - vi.resetModules(); - const highlighter = await import("./shikiReviewHighlighter"); - const source = "const answer: number = 42;"; +describe("highlightReviewSelectedLines", () => { + it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { + const lines: ReviewRenderableLineRow[] = [ + { + kind: "line", + id: "delete-1", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + content: "const before = 1;", + additionTokenIndex: null, + deletionTokenIndex: 0, + comparison: { change: "add", tokenIndex: 0 }, + }, + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: "const after = 2;", + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: { change: "delete", tokenIndex: 0 }, + }, + ]; - const highlighted = await highlighter.highlightSourceFile({ - path: "example.ts", - contents: source, + const highlighted = await highlightReviewSelectedLines({ + filePath: "apps/mobile/src/example-inline-diff.txt", + lines, + theme: "light", + }); + + expect(highlighted["delete-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + expect(highlighted["add-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + }); +}); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", theme: "dark", }); @@ -158,8 +125,5 @@ describe("highlightSourceFile", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); - expect( - await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), - ).toEqual(highlighted); }); }); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index c684a6686430..8fa7f69a433c 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -17,7 +17,7 @@ import { resolveReviewHighlighterEnginePreference, type ReviewHighlighterEngine, } from "./reviewHighlighterEngine"; -import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; +import type { ReviewRenderableLineRow } from "./reviewModel"; import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; export type ReviewDiffTheme = "light" | "dark"; @@ -43,17 +43,6 @@ export interface ReviewHighlightedToken { readonly diffHighlight?: boolean; } -export interface ReviewHighlightedFile { - readonly additionLines: ReadonlyArray>; - readonly deletionLines: ReadonlyArray>; -} - -export interface ReviewHighlightFileProgress { - readonly highlightedFile: ReviewHighlightedFile; - readonly complete: boolean; - readonly highlightedLineCount: number; -} - const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", dark: "github-dark-default", @@ -64,16 +53,9 @@ const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); -const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, - false, -); -const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; -const highlightCache = new Map>(); -const resolvedHighlightCache = new Map(); const REVIEW_INITIAL_LANGUAGE_MODULES = [ bashLanguage, javascriptLanguage, @@ -204,22 +186,6 @@ type LoadedLanguageModule = { default: Parameters[0]; }; -function resolveReviewHighlighterBooleanFlag( - value: string | undefined, - defaultValue: boolean, -): boolean { - switch (value) { - case "1": - case "true": - return true; - case "0": - case "false": - return false; - default: - return defaultValue; - } -} - function isReviewHighlighterDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -267,7 +233,6 @@ async function getHighlighter(): Promise { logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, - resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, }); const themes = [githubLightDefault, githubDarkDefault]; @@ -488,13 +453,6 @@ async function resolveLanguageFromPath( return candidate; } -async function resolveLanguage(file: ReviewRenderableFile): Promise { - return ( - resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? - (await resolveLanguageFromPath(file.path, file.languageHint)) - ); -} - function normalizeHighlightedLines( tokenLines: ReadonlyArray>, ): ReadonlyArray> { @@ -507,84 +465,6 @@ function normalizeHighlightedLines( ); } -function makePlainHighlightedLines( - lines: ReadonlyArray, -): ReadonlyArray> { - return lines.map((line) => [ - { - content: stripTrailingNewline(line), - color: null, - fontStyle: null, - }, - ]); -} - -function applyWordAltDiffHighlightsToFile( - file: ReviewRenderableFile, - highlighted: ReviewHighlightedFile, -): ReviewHighlightedFile { - const nextAdditionLines = [...highlighted.additionLines]; - const nextDeletionLines = [...highlighted.deletionLines]; - const processedPairs = new Set(); - let changed = false; - - file.rows.forEach((row) => { - if (row.kind !== "line" || row.change === "context" || !row.comparison) { - return; - } - - const deletionTokenIndex = - row.change === "delete" - ? row.deletionTokenIndex - : row.comparison.change === "delete" - ? row.comparison.tokenIndex - : null; - const additionTokenIndex = - row.change === "add" - ? row.additionTokenIndex - : row.comparison.change === "add" - ? row.comparison.tokenIndex - : null; - - if (deletionTokenIndex === null || additionTokenIndex === null) { - return; - } - - const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; - if (processedPairs.has(pairKey)) { - return; - } - processedPairs.add(pairKey); - - const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); - const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); - const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); - - if (ranges.deletion.length > 0) { - nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( - nextDeletionLines[deletionTokenIndex] ?? [], - ranges.deletion, - ); - changed = true; - } - - if (ranges.addition.length > 0) { - nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( - nextAdditionLines[additionTokenIndex] ?? [], - ranges.addition, - ); - changed = true; - } - }); - - return changed - ? { - additionLines: nextAdditionLines, - deletionLines: nextDeletionLines, - } - : highlighted; -} - function applyWordAltDiffHighlightsToSelectedLines(input: { readonly lines: ReadonlyArray; readonly tokenMap: Record>; @@ -733,292 +613,6 @@ export async function highlightSourceFile(input: { return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } -async function highlightPatchLinesInChunks(input: { - readonly lines: ReadonlyArray; - readonly language: string; - readonly theme: string; - readonly onChunk: ( - startIndex: number, - tokens: ReadonlyArray>, - ) => void; -}): Promise>> { - if (input.lines.length === 0) { - return []; - } - - const highlighter = await getHighlighter(); - const highlightedLines: Array> = []; - - for ( - let startIndex = 0; - startIndex < input.lines.length; - startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE - ) { - const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); - const chunkTokens: Array> = []; - const tokenizableLines: string[] = []; - const tokenizableIndexes: number[] = []; - - lineChunk.forEach((line, index) => { - const strippedLine = stripTrailingNewline(line); - if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { - chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; - return; - } - - tokenizableIndexes.push(index); - tokenizableLines.push(strippedLine); - }); - - if (tokenizableLines.length > 0) { - const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { - lang: input.language, - theme: input.theme, - }); - const normalizedTokenLines = normalizeHighlightedLines(tokenLines); - - tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { - chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; - }); - } - - const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); - highlightedLines.push(...completedChunk); - input.onChunk(startIndex, completedChunk); - - if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { - await waitForNextFrame(); - } - } - - return highlightedLines; -} - -function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { - return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; -} - -function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { - if (resolvedHighlightCache.has(cacheKey)) { - resolvedHighlightCache.delete(cacheKey); - } - - resolvedHighlightCache.set(cacheKey, highlighted); - - while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { - const oldestKey = resolvedHighlightCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - resolvedHighlightCache.delete(oldestKey); - } -} - -export async function highlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return resolved; - } - const cached = highlightCache.get(cacheKey); - if (cached) { - logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return cached; - } - } - - const promise = (async () => { - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines = await highlightLines( - joinPatchLines(file.additionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - const deletionLines = await highlightLines( - joinPatchLines(file.deletionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - })(); - - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.set(cacheKey, promise); - } - return promise.finally(() => { - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.delete(cacheKey); - } - }); -} - -export async function streamHighlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, - onProgress: (progress: ReviewHighlightFileProgress) => void, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - onProgress({ - highlightedFile: resolved, - complete: true, - highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, - }); - return resolved; - } - } - - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file stream highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines: Array> = []; - const deletionLines: Array> = []; - let highlightedLineCount = 0; - - await highlightPatchLinesInChunks({ - lines: file.additionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - additionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - await waitForNextFrame(); - await highlightPatchLinesInChunks({ - lines: file.deletionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - deletionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount, - durationMs: Date.now() - startedAt, - }); - return highlighted; -} - export async function highlightReviewSelectedLines(input: { readonly filePath: string; readonly lines: ReadonlyArray; From 0acf05f4f561b873853587f8629ca691b18a613a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:54:25 -0700 Subject: [PATCH 087/103] test(server): cover CLI runner detection through command suggestions (#10066) --- apps/server/src/cli/invocation.test.ts | 81 +++++++++++++++----------- apps/server/src/cli/invocation.ts | 4 +- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index c01a2caa49b5..370a8977fc4c 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,49 +1,62 @@ import { assert, it } from "@effect/vitest"; -import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; +import { formatCliCommand } from "./invocation.ts"; -it("detects package runners from their cache entry paths", () => { - assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx"); - assert.equal( - detectCliRunner( +it("formats package runner commands from their cache entry paths", () => { + for (const [entryPath, expected] of [ + ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "npx", - ); - assert.equal( - detectCliRunner("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner( + "npx t3 serve", + ], + ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"], + [ + "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + "pnpm dlx t3 serve", + ], + [ "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "pnpm dlx", - ); - assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx"); - assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx"); - assert.equal( - detectCliRunner( + "pnpm dlx t3 serve", + ], + ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"], + ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - ), - "bunx", - ); + "bunx t3 serve", + ], + ] as const) { + assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); + } }); it("treats stable installs as direct invocations", () => { - assert.isNull(detectCliRunner("/usr/local/lib/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("")); + for (const entryPath of [ + "/usr/local/lib/node_modules/t3/dist/bin.mjs", + "/home/theo/Code/work/t3code/apps/server/dist/bin.mjs", + "/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs", + "", + ]) { + assert.equal( + formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), + "t3 serve", + ); + } }); it("re-suggests the nightly channel only for nightly builds", () => { - assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly"); - assert.equal(suggestedPackageSpec("0.0.31"), "t3"); + for (const [version, expected] of [ + ["0.0.31-nightly.20260729", "npx t3@nightly serve"], + ["0.0.31", "npx t3 serve"], + ] as const) { + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", + version, + }), + expected, + ); + } }); it("formats serve suggestions to match the launching command", () => { diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index e1b03552948d..55f5b66ad9dd 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -18,7 +18,7 @@ export type CliRunner = "npx" | "pnpm dlx" | "bunx"; * Global installs and repo checkouts match none of these and return null. * Detection is best-effort; callers must fail closed to a plain `t3` command. */ -export function detectCliRunner(entryPath: string): CliRunner | null { +function detectCliRunner(entryPath: string): CliRunner | null { const path = entryPath.replaceAll("\\", "/"); if (path.includes("/_npx/")) { return "npx"; @@ -42,7 +42,7 @@ export function detectCliRunner(entryPath: string): CliRunner | null { * from the running version: nightly builds re-suggest the nightly channel, * anything else suggests the bare package. */ -export function suggestedPackageSpec(version: string): string { +function suggestedPackageSpec(version: string): string { return version.includes("-nightly.") ? "t3@nightly" : "t3"; } From ee150e178be5e0484a588a8fe7ad98fd6b858973 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:54:36 -0700 Subject: [PATCH 088/103] refactor(mobile): remove unused cloud relay URL normalizer (#10068) --- apps/mobile/src/features/cloud/linkEnvironment.test.ts | 8 -------- apps/mobile/src/features/cloud/linkEnvironment.ts | 8 -------- 2 files changed, 16 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 42aa8ffebb61..b1280db91615 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -17,7 +17,6 @@ import { connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, - normalizeRelayBaseUrl, refreshCloudEnvironmentConnection, } from "./linkEnvironment"; @@ -195,13 +194,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("normalizes configured relay base URLs before building DPoP-bound requests", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - }); - it("makes linked environments visible while their status is still loading", () => { expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index c2033117f69d..be63cd5877bd 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -43,14 +43,6 @@ const RELAY_STATUS_AND_CONNECT_SCOPES = [ RelayEnvironmentConnectScope, ] satisfies ReadonlyArray; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } From 009c13fade9dde2fe7eed068c72c579172f12e60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:54:47 -0700 Subject: [PATCH 089/103] refactor(web): test live keybinding resolvers directly (#10069) --- apps/web/src/keybindings.test.ts | 57 ++++++++++++++++++-------------- apps/web/src/keybindings.ts | 24 -------------- 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8cbc45966529..6390269ea0d9 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -8,8 +8,6 @@ import { } from "@t3tools/contracts"; import { formatShortcutLabel, - isChatNewShortcut, - isChatNewLocalShortcut, isDiffToggleShortcut, modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -22,7 +20,7 @@ import { isTerminalToggleShortcut, resolveShortcutCommand, shouldShowModelPickerJumpHints, - shouldShowThreadJumpHints, + shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -498,17 +496,21 @@ describe("thread navigation helpers", () => { it("shows jump hints only when configured modifiers match", () => { assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), ); assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - }), + shouldShowThreadJumpHintsForModifiers( + event({ metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), ); assert.isTrue( - shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), ); @@ -516,13 +518,13 @@ describe("thread navigation helpers", () => { it("never shows jump hints while the terminal is focused, even with an unrestricted binding", () => { assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true }, }), ); assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: false }, }), @@ -558,28 +560,32 @@ describe("model picker navigation helpers", () => { describe("chat/editor shortcuts", () => { it("matches chat.new shortcut", () => { - assert.isTrue( - isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.new", ); - assert.isTrue( - isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.new", ); }); it("matches chat.newLocal shortcut", () => { - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.newLocal", ); - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.newLocal", ); }); @@ -699,11 +705,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isFalse( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: true }, }), + "terminal.new", ); assert.isFalse( isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { @@ -711,11 +718,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: false }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: false }, }), + "chat.new", ); }); @@ -735,11 +743,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true }), keybindings, { platform: "Linux", context: { terminalFocus: true }, }), + "chat.new", ); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 2a8d385cb42b..62f107a4daad 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -288,14 +288,6 @@ export function threadTraversalDirectionFromCommand( return null; } -export function shouldShowThreadJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); -} - export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, @@ -419,22 +411,6 @@ export function isPreviewRefreshShortcut( return matchesCommandShortcut(event, keybindings, "preview.refresh", options); } -export function isChatNewShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.new", options); -} - -export function isChatNewLocalShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); -} - export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, From 25cbcd62d9750d3e8903fd752078e66c7943ecdb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:55:26 -0700 Subject: [PATCH 090/103] test(server): cover Grok skill parsing through discovery (#10070) --- .../src/provider/Drivers/GrokSkills.test.ts | 241 ++++++++++-------- .../server/src/provider/Drivers/GrokSkills.ts | 4 - 2 files changed, 136 insertions(+), 109 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 13415bc35de3..ce8a31b51985 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,141 +1,172 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); -describe("parseGrokInspectSkills", () => { - it("maps inspect entries onto provider skills, sorted by name", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { - name: "writing-docs", - description: "Write user docs.", - source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, - userInvocable: true, - }, +const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array) => + ChildProcessSpawner.make((command) => { + spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("discoverGrokSkills", () => { + it.effect("maps inspect entries onto provider skills, sorted by name", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + + expect(skills).toEqual([ { name: "deploy", description: "Deploy the app.", - source: { - type: "plugin", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - }, - userInvocable: true, + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, }, - ]), - ); + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ), + ), + ), + ); - expect(skills).toEqual([ - { - name: "deploy", - description: "Deploy the app.", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - scope: "plugin", - enabled: true, - }, - { - name: "writing-docs", - description: "Write user docs.", - path: "/home/dev/.grok/skills/writing-docs/SKILL.md", - scope: "user", - enabled: true, - }, - ]); - }); + it.effect("disables skills the CLI marks as not user-invocable", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); - it("disables skills the CLI marks as not user-invocable", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ + expect(skills).toEqual([ { name: "internal-helper", - source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, - userInvocable: false, + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, }, - ]), - ); - - expect(skills).toEqual([ - { - name: "internal-helper", - path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", - scope: "bundled", - enabled: false, - }, - ]); - }); - - it("skips entries without a name or a filesystem path", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, - { name: "no-path", source: { type: "user" } }, - { name: "no-source" }, - "not-an-object", - { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, - ]), - ); + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "internal-helper", + source: { + type: "bundled", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + }, + userInvocable: false, + }, + ]), + ), + ), + ), + ); - expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - }); + it.effect("skips entries without a name or a filesystem path", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ), + ), + ), + ); - it("returns an empty list for malformed or unexpected output", () => { - expect(parseGrokInspectSkills("not json")).toEqual([]); - expect(parseGrokInspectSkills("null")).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); - }); -}); + it.effect("rejects malformed or unexpected output as a decode failure", () => + Effect.gen(function* () { + for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) { + const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe( + Effect.flip, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout), + ), + ); + expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" }); + } + }), + ); -describe("discoverGrokSkills", () => { it.effect("spawns in the configured cwd and rejects a failed probe", () => { const spawnCwds: Array = []; - let exitCode = 0; - const spawner = ChildProcessSpawner.make((command) => { - spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.make( - inspectPayload([ - { - name: "kept", - source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, - }, - ]), - ), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); + const stdout = inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]); return Effect.gen(function* () { const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 0, spawnCwds), + ), ); expect(spawnCwds).toEqual(["/workspaces/demo"]); expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - exitCode = 1; const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( Effect.result, - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 1), + ), ); expect(failed._tag).toBe("Failure"); }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index b7962205d346..53a20049ad5d 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -91,10 +91,6 @@ function decodeGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } -export function parseGrokInspectSkills(stdout: string): ReadonlyArray { - return decodeGrokInspectSkills(stdout) ?? []; -} - /** * Run `grok inspect --json` and map the reported catalog onto provider * skills. Callers that need best-effort discovery can recover this effect to From 38812c101e562c4f8d9bea7b1a592a4b02954a39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:56:35 -0700 Subject: [PATCH 091/103] test(web): remove mocked diff view prop snapshot (#10073) --- .../diffs/StyledDiffCodeView.test.tsx | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index 6a746b40f9af..ee249888a415 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -18,13 +18,10 @@ import { useState, type Ref, } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ - codeViewClassName: null as string | null, - codeViewOptions: null as Record | null, workers: [] as NodeWorkerThreads.Worker[], terminations: [] as Promise[], requests: [] as WorkerRequest[], @@ -102,8 +99,6 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => { vi.mock("@pierre/diffs/react", async (importOriginal) => ({ ...(await importOriginal()), CodeView: (props: CodeViewProps) => { - testState.codeViewClassName = props.className ?? null; - testState.codeViewOptions = props.options ? { ...props.options } : null; return props.items?.map((item) => item.type === "file" ? : null, ); @@ -158,49 +153,6 @@ function renderViews(count: number) { ); } -describe("StyledDiffCodeView", () => { - beforeEach(() => { - testState.codeViewClassName = null; - testState.codeViewOptions = null; - }); - - it("always pairs the shared diff styling with its virtualized geometry", () => { - const loadDiffFiles = vi.fn(async () => ({ - oldFile: { name: "before.ts", contents: "before\n" }, - newFile: { name: "after.ts", contents: "after\n" }, - })); - renderToStaticMarkup( - , - ); - - expect(testState.codeViewClassName).toBe( - "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", - ); - expect(testState.codeViewOptions).toMatchObject({ - theme: "pierre-dark", - stickyHeaders: true, - loadDiffFiles, - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - paddingTop: 0, - paddingBottom: 8, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, - }); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining("[data-unmodified-lines]::before"), - ); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), - ); - }); -}); - describe("code-view worker lifecycle", () => { let renderer: ReactTestRenderer | undefined; From 19ea3c5beb4cd93dd19f51f3e7c6d760357b41df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:56:48 -0700 Subject: [PATCH 092/103] test(web): remove mocked annotation options snapshot (#10074) --- .../diffs/AnnotatableCodeView.test.tsx | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 apps/web/src/components/diffs/AnnotatableCodeView.test.tsx diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx deleted file mode 100644 index 81cd5625e62b..000000000000 --- a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - codeViewOptions: null as Record | null, -})); - -vi.mock("@pierre/diffs/react", () => ({ - CodeView: (props: { options: Record }) => { - testState.codeViewOptions = props.options; - return null; - }, -})); - -vi.mock("../DiffWorkerPoolProvider", () => ({ - DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, -})); - -vi.mock("~/composerDraftStore", () => ({ - useComposerDraftStore: (selector: (store: Record) => unknown) => - selector({ - addReviewComment: vi.fn(), - removeReviewComment: vi.fn(), - getComposerDraft: () => undefined, - }), -})); - -vi.mock("./DiffCommentAnnotation", () => ({ - DiffCommentAnnotation: () => null, -})); - -vi.mock("../files/fileCommentAnnotations", () => ({ - nextFileCommentId: () => "comment-test", -})); - -import { AnnotatableCodeView } from "./AnnotatableCodeView"; - -describe("AnnotatableCodeView", () => { - beforeEach(() => { - testState.codeViewOptions = null; - }); - - it("opens comments from Pierre's gutter action without ending line selection", () => { - renderToStaticMarkup( - null} - renderHeaderFilenameSuffix={() => null} - />, - ); - - expect(testState.codeViewOptions).toMatchObject({ - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: expect.any(Function), - }); - expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd"); - }); -}); From 688e5948046e14be7f2bfd48d1c91b7e94d79267 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:57:00 -0700 Subject: [PATCH 093/103] refactor(web): keep pending action labels private (#10075) --- .../chat/ComposerPrimaryActions.test.tsx | 92 +------------------ .../chat/ComposerPrimaryActions.tsx | 2 +- 2 files changed, 2 insertions(+), 92 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 92f24c833db8..45ef93568cf6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -15,7 +15,7 @@ vi.mock("../SidebarStageBackdrop", () => ({ useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), })); -import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; function renderPendingActions(isRunning: boolean) { return renderToStaticMarkup( @@ -92,96 +92,6 @@ afterEach(() => { stageArtworkState.variant = null; }); -describe("formatPendingPrimaryActionLabel", () => { - it("returns 'Submitting...' while responding", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: true, - questionIndex: 0, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submitting...' while responding regardless of other flags", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: true, - questionIndex: 3, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submit' in compact mode on the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit"); - }); - - it("returns 'Next' in compact mode when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: false, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Next"); - }); - - it("returns 'Next question' when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Next question"); - }); - - it("returns singular 'Submit answer' on the last question when it is the only question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit answer"); - }); - - it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Submit answers"); - }); - - it("returns plural 'Submit answers' for higher question indices", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 5, - }), - ).toBe("Submit answers"); - }); -}); - describe("ComposerPrimaryActions", () => { it("disables and labels the send button while feedback is uploading", () => { const markup = renderSendButton("Sending feedback"); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 46323aa2b09a..91c54b75ed03 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -37,7 +37,7 @@ interface ComposerPrimaryActionsProps { onImplementPlanInNewThread: () => void; } -export const formatPendingPrimaryActionLabel = (input: { +const formatPendingPrimaryActionLabel = (input: { compact: boolean; isLastQuestion: boolean; isResponding: boolean; From b92a8122876458a67cc225f75c1d31bd3e33dc30 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 02:02:12 -0700 Subject: [PATCH 094/103] refactor(mobile): remove unused cloud pending-status mapper (#10071) --- .../mobile/src/features/cloud/linkEnvironment.test.ts | 11 ----------- apps/mobile/src/features/cloud/linkEnvironment.ts | 10 ---------- 2 files changed, 21 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b1280db91615..feadf6c81893 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -11,7 +11,6 @@ import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; import { - cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, linkEnvironmentToCloudWithPreference, connectCloudEnvironment, @@ -194,16 +193,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("makes linked environments visible while their status is still loading", () => { - expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ - { - environment: { environmentId: "env-1", label: "Desktop" }, - status: null, - statusError: "Checking status...", - }, - ]); - }); - it.effect("decodes relay environment list responses before returning records", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index be63cd5877bd..b8dc8f878e0c 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -397,16 +397,6 @@ export function getCloudEnvironmentStatus(input: { }); } -export function cloudEnvironmentsPendingStatus( - environments: ReadonlyArray, -): ReadonlyArray { - return environments.map((environment) => ({ - environment, - status: null, - statusError: "Checking status...", - })); -} - export function loadCloudEnvironmentStatuses(input: { readonly clerkToken: string; readonly environments: ReadonlyArray; From ab0933a41ae3a69632ea68809700a040bde26c22 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 02:04:27 -0700 Subject: [PATCH 095/103] refactor(web): remove unused model picker hint helpers (#10072) --- apps/web/src/keybindings.test.ts | 16 ---------------- apps/web/src/keybindings.ts | 26 -------------------------- 2 files changed, 42 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 6390269ea0d9..df005571193e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -19,7 +19,6 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, - shouldShowModelPickerJumpHints, shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, terminalDeleteShortcutData, @@ -541,21 +540,6 @@ describe("model picker navigation helpers", () => { assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); }); - - it("shows jump hints only while the model picker context is active", () => { - assert.isFalse( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: false }, - }), - ); - assert.isTrue( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: true }, - }), - ); - }); }); describe("chat/editor shortcuts", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 62f107a4daad..1b4fa072d5a1 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -329,32 +329,6 @@ export function modelPickerJumpIndexFromCommand(command: string): number | null return index === -1 ? null : index; } -export function shouldShowModelPickerJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options); -} - -export function shouldShowModelPickerJumpHintsForModifiers( - modifiers: ShortcutModifierStateLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - const platform = resolvePlatform(options); - - for (const command of MODEL_PICKER_JUMP_KEYBINDING_COMMANDS) { - const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); - if (!shortcut) continue; - if (matchesShortcutModifiers(modifiers, shortcut, platform)) { - return true; - } - } - - return false; -} - export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, From c843c19294bcea9a4f5cf19632b135459a16d214 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 5 Sep 2026 04:12:01 -0500 Subject: [PATCH 096/103] fix(server): resume checkpointing after git init (#10078) --- .../Layers/CheckpointReactor.test.ts | 184 ++++++++++++++---- .../orchestration/Layers/CheckpointReactor.ts | 75 +++---- 2 files changed, 182 insertions(+), 77 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 390e61138f08..cb1725882fba 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -292,6 +292,7 @@ describe("CheckpointReactor", () => { async function createHarness(options?: { readonly hasSession?: boolean; readonly seedFilesystemCheckpoints?: boolean; + readonly initializeGit?: boolean; readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly threadBranch?: string | null; @@ -303,6 +304,9 @@ describe("CheckpointReactor", () => { readonly pullRequestRefreshCalls?: Array; }) { const cwd = createGitRepository(); + if (options?.initializeGit === false) { + NodeFS.rmSync(NodePath.join(cwd, ".git"), { recursive: true }); + } tempDirs.push(cwd); const provider = createProviderServiceHarness( cwd, @@ -1158,52 +1162,148 @@ describe("CheckpointReactor", () => { ).toBe(true); }); - it("appends capture failure activity when turn diff summary cannot be derived", async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = "2026-01-01T00:00:00.000Z"; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), + effectIt.effect("captures a checkpoint without a summary when the baseline is missing", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ seedFilesystemCheckpoints: false }), + ); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-missing-baseline"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.make("evt-turn-completed-missing-baseline"), - provider: ProviderDriverKind.make("codex"), + turnId: asTurnId("turn-missing-baseline"), + payload: { state: "completed" }, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[0]).toMatchObject({ + status: "ready", + checkpointTurnCount: 1, + files: [], + }); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), + ).toBe(true); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); - createdAt: "2026-01-01T00:00:00.000Z", - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-missing-baseline"), - payload: { state: "completed" }, - }); + effectIt.effect.each([ + { timing: "between turns", commit: false }, + { timing: "between turns", commit: true }, + { timing: "during a turn", commit: false }, + { timing: "during a turn", commit: true }, + ])("resumes checkpointing after git init $timing (commit: $commit)", ({ timing, commit }) => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ initializeGit: false, seedFilesystemCheckpoints: false }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const emit = (type: "turn.started" | "turn.completed", turn: number) => + harness.provider.emit({ + type, + eventId: EventId.make(`${type}-${turn}`), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: asTurnId(`turn-${turn}`), + ...(type === "turn.completed" ? { payload: { state: "completed" } } : {}), + }); + emit("turn.started", 1); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "before git\n"); + emit("turn.completed", 1); + yield* Effect.promise(harness.drain); + expect((yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints).toEqual([]); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.checkpoints.length === 1 && - entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ); + if (timing === "during a turn") { + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + runGit(harness.cwd, ["init", "--initial-branch=main"]); + if (commit) { + runGit(harness.cwd, ["add", "."]); + runGit(harness.cwd, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "Initial", + ]); + } + if (timing === "between turns") { + // Exercise the domain entry point as well as the provider turn-start event. + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-after-git-init"), + threadId, + message: { + messageId: MessageId.make("message-after-git-init"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + checkpointTurnCount: 0, + }); + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "after git\n"); + emit("turn.completed", 2); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); + yield* Effect.promise(harness.drain); + const firstCheckpoint = (yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints[0]; + expect(firstCheckpoint?.files).toEqual( + timing === "between turns" + ? [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }] + : [], + ); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "README.md"), + ).toBe("after git\n"); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 0))).toBe( + timing === "between turns", + ); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect( - thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ).toBe(true); - }); + emit("turn.started", 3); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "next turn\n"); + emit("turn.completed", 3); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 2, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[1]?.files).toEqual([ + { path: "README.md", kind: "modified", additions: 1, deletions: 1 }, + ]); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index f155a6ae365c..de1cb29c5cd9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -258,41 +258,46 @@ const make = Effect.gen(function* () { // reflects files created or deleted during this turn. yield* workspaceEntries.refresh(input.cwd); - const files = yield* checkpointStore - .diffCheckpoints({ - cwd: input.cwd, - fromCheckpointRef, - toCheckpointRef: targetCheckpointRef, - fallbackFromToHead: false, - ignoreWhitespace: false, - format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified" as const, - additions: file.additions, - deletions: file.deletions, - })), - ), - Effect.tapError((error) => - appendCaptureFailureActivity({ - threadId: input.threadId, - turnId: input.turnId, - detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, - createdAt: input.createdAt, - }), - ), - Effect.catch((error) => - Effect.logWarning("failed to derive checkpoint file summary", { - threadId: input.threadId, - turnId: input.turnId, - turnCount: input.turnCount, - detail: error.message, - }).pipe(Effect.as([])), - ), - ); + // Git may have been initialized during this turn, leaving no pre-turn + // snapshot. Keep the completion checkpoint for future turns, but do not + // invent a baseline or attempt a diff against a ref that does not exist. + const files = yield* ( + fromCheckpointExists + ? checkpointStore.diffCheckpoints({ + cwd: input.cwd, + fromCheckpointRef, + toCheckpointRef: targetCheckpointRef, + fallbackFromToHead: false, + ignoreWhitespace: false, + format: "numstat", + }) + : Effect.succeed("") + ).pipe( + Effect.map((diff) => + parseTurnDiffFilesFromNumstat(diff).map((file) => ({ + path: file.path, + kind: "modified" as const, + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.tapError((error) => + appendCaptureFailureActivity({ + threadId: input.threadId, + turnId: input.turnId, + detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, + createdAt: input.createdAt, + }), + ), + Effect.catch((error) => + Effect.logWarning("failed to derive checkpoint file summary", { + threadId: input.threadId, + turnId: input.turnId, + turnCount: input.turnCount, + detail: error.message, + }).pipe(Effect.as([])), + ), + ); const assistantMessageId = input.assistantMessageId ?? From 09aac71563c66a4f65f6fbe701aa9596cb677767 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 02:22:30 -0700 Subject: [PATCH 097/103] feat(web): first-run welcome wizard with agent setup and project import (#5362) Co-authored-by: Claude Fable 5 --- .../DesktopClientSettings.diagnostics.test.ts | 23 +- .../settings/DesktopClientSettings.test.ts | 92 +- .../src/settings/DesktopClientSettings.ts | 60 +- apps/server/src/auth/RpcAuthorization.test.ts | 9 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/CheckpointReactor.test.ts | 30 + .../orchestration/Layers/CheckpointReactor.ts | 1 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 96 + .../Layers/ProjectionPipeline.ts | 19 +- .../Layers/ProjectionSnapshotQuery.test.ts | 299 +- .../Layers/ProjectionSnapshotQuery.ts | 109 +- .../Services/ProjectionSnapshotQuery.ts | 10 + .../src/orchestration/decider.import.test.ts | 509 +++ apps/server/src/orchestration/decider.ts | 96 +- apps/server/src/orchestration/projector.ts | 20 +- .../Layers/ProjectionThreadMessages.test.ts | 14 +- .../Layers/ProjectionThreadMessages.ts | 1 + .../src/persistence/ProviderSessionRuntime.ts | 151 +- .../src/project/AgentSessionImporter.test.ts | 1213 +++++++ .../src/project/AgentSessionImporter.ts | 297 ++ .../src/project/AgentSessionScanner.test.ts | 3088 +++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 1313 +++++++ .../project/ProjectSetupScriptRunner.test.ts | 1 + .../src/provider/Drivers/ClaudeDriver.ts | 7 +- .../src/provider/Drivers/CodexDriver.ts | 2 + .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 1 + .../ProviderInstanceRegistryLive.test.ts | 133 +- .../provider/Layers/ProviderService.test.ts | 1 + .../Layers/ProviderSessionDirectory.test.ts | 266 +- .../Layers/ProviderSessionDirectory.ts | 55 +- .../Layers/ProviderSessionReaper.test.ts | 1 + .../ProviderInstanceEnvironment.test.ts | 49 +- .../provider/ProviderInstanceEnvironment.ts | 8 +- .../Services/ProviderSessionDirectory.ts | 12 + .../testFixtures/codexCollabMockPeer.mjs | 8 + .../src/relay/AgentAwarenessRelay.test.ts | 98 +- apps/server/src/relay/AgentAwarenessRelay.ts | 3 + apps/server/src/server.test.ts | 203 ++ .../serverRuntimeStartup.reconcile.test.ts | 11 + apps/server/src/serverRuntimeStartup.test.ts | 89 + apps/server/src/serverRuntimeStartup.ts | 127 +- apps/server/src/serverSettings.test.ts | 37 + apps/server/src/terminal/Manager.test.ts | 412 +++ apps/server/src/terminal/Manager.ts | 261 +- apps/server/src/ws.ts | 41 +- apps/web/src/authBootstrap.test.ts | 57 + .../src/browser/HostedBrowserWebview.test.tsx | 200 ++ apps/web/src/browser/HostedBrowserWebview.tsx | 11 +- apps/web/src/browser/browserDefaults.test.ts | 25 +- apps/web/src/browser/browserDefaults.ts | 1 + .../web/src/browser/browserLinkTarget.test.ts | 28 +- apps/web/src/browser/browserLinkTarget.ts | 1 + apps/web/src/browser/browserRecording.test.ts | 31 + apps/web/src/browser/browserRecording.ts | 10 +- .../src/browser/desktopTabLifetime.test.ts | 35 +- apps/web/src/browser/openFileInPreview.ts | 24 +- apps/web/src/browser/useOpenLink.ts | 13 +- apps/web/src/clientPersistenceStorage.test.ts | 39 +- apps/web/src/clientPersistenceStorage.ts | 7 +- apps/web/src/components/ChatMarkdown.tsx | 17 +- apps/web/src/components/ChatView.tsx | 13 + .../components/ThreadTerminalDrawer.test.ts | 89 +- .../src/components/ThreadTerminalDrawer.tsx | 77 +- .../CloudEnvironmentConnectList.test.tsx | 214 ++ .../cloud/CloudEnvironmentConnectList.tsx | 60 +- .../components/onboarding/FirstRunGate.tsx | 244 ++ .../components/onboarding/WelcomeWizard.tsx | 1478 ++++++++ .../preview/PreviewAutomationHosts.test.tsx | 190 + .../preview/PreviewAutomationHosts.tsx | 10 +- .../src/components/preview/PreviewView.tsx | 16 +- .../preview/addBrowserSurface.test.ts | 3 + .../components/preview/addBrowserSurface.ts | 4 +- .../components/preview/openDiscoveredPort.ts | 4 +- .../preview/openPreviewSession.test.ts | 55 +- .../components/preview/openPreviewSession.ts | 12 +- .../preview/openTerminalLinkInPreview.test.ts | 27 + .../settings/providerStatus.test.ts | 71 + .../src/components/settings/providerStatus.ts | 21 +- apps/web/src/environments/primary/auth.ts | 2 + apps/web/src/hooks/useLocalStorage.test.ts | 23 +- apps/web/src/hooks/useLocalStorage.ts | 40 +- apps/web/src/hooks/useSettings.test.ts | 180 +- apps/web/src/hooks/useSettings.ts | 91 +- apps/web/src/hooks/useTheme.test.ts | 197 ++ apps/web/src/hooks/useTheme.ts | 84 +- apps/web/src/index.css | 40 +- .../web/src/onboarding/firstRun.logic.test.ts | 514 +++ apps/web/src/onboarding/firstRun.logic.ts | 184 + apps/web/src/onboarding/firstRun.ts | 16 + .../onboarding/projectImport.logic.test.ts | 245 ++ .../web/src/onboarding/projectImport.logic.ts | 55 + .../providerReadiness.logic.test.ts | 317 ++ .../src/onboarding/providerReadiness.logic.ts | 99 + .../targetEnvironment.logic.test.ts | 211 ++ .../src/onboarding/targetEnvironment.logic.ts | 49 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 74 +- apps/web/src/routes/_chat.index.tsx | 9 +- apps/web/src/routes/welcome.tsx | 45 + apps/web/src/state/agentSessions.ts | 25 + docs/user/welcome-wizard.md | 62 + .../client-runtime/src/rpc/client.test.ts | 76 +- packages/client-runtime/src/rpc/client.ts | 49 +- .../client-runtime/src/state/server.test.ts | 218 +- packages/client-runtime/src/state/server.ts | 152 +- .../src/state/threadReducer.test.ts | 128 + .../client-runtime/src/state/threadReducer.ts | 52 +- packages/contracts/src/agentSessions.ts | 98 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.test.ts | 15 + packages/contracts/src/orchestration.ts | 17 + packages/contracts/src/rpc.ts | 30 + packages/contracts/src/server.ts | 3 + packages/contracts/src/settings.ts | 8 + packages/contracts/src/terminal.test.ts | 43 + packages/contracts/src/terminal.ts | 35 +- packages/shared/package.json | 4 + packages/shared/src/dateTime.test.ts | 92 + packages/shared/src/dateTime.ts | 38 + 122 files changed, 15520 insertions(+), 494 deletions(-) create mode 100644 apps/server/src/orchestration/decider.import.test.ts create mode 100644 apps/server/src/project/AgentSessionImporter.test.ts create mode 100644 apps/server/src/project/AgentSessionImporter.ts create mode 100644 apps/server/src/project/AgentSessionScanner.test.ts create mode 100644 apps/server/src/project/AgentSessionScanner.ts create mode 100644 apps/web/src/browser/HostedBrowserWebview.test.tsx create mode 100644 apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx create mode 100644 apps/web/src/components/onboarding/FirstRunGate.tsx create mode 100644 apps/web/src/components/onboarding/WelcomeWizard.tsx create mode 100644 apps/web/src/components/preview/PreviewAutomationHosts.test.tsx create mode 100644 apps/web/src/components/settings/providerStatus.test.ts create mode 100644 apps/web/src/onboarding/firstRun.logic.test.ts create mode 100644 apps/web/src/onboarding/firstRun.logic.ts create mode 100644 apps/web/src/onboarding/firstRun.ts create mode 100644 apps/web/src/onboarding/projectImport.logic.test.ts create mode 100644 apps/web/src/onboarding/projectImport.logic.ts create mode 100644 apps/web/src/onboarding/providerReadiness.logic.test.ts create mode 100644 apps/web/src/onboarding/providerReadiness.logic.ts create mode 100644 apps/web/src/onboarding/targetEnvironment.logic.test.ts create mode 100644 apps/web/src/onboarding/targetEnvironment.logic.ts create mode 100644 apps/web/src/routes/welcome.tsx create mode 100644 apps/web/src/state/agentSessions.ts create mode 100644 docs/user/welcome-wizard.md create mode 100644 packages/contracts/src/agentSessions.ts create mode 100644 packages/shared/src/dateTime.test.ts create mode 100644 packages/shared/src/dateTime.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44cf70..d2fd166e878c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer) => { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, records, }; @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { Effect.gen(function* () { const result = yield* readWithLogs(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.deepEqual(result.records, []); }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("reports non-missing filesystem failures and logs the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.strictEqual(result.result.failure.cause, permissionError); assert.equal(result.records.length, 1); assert.deepEqual(result.records[0]?.message, [ "Could not read desktop client settings.", @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("reports malformed settings documents and logs the settings path", () => Effect.gen(function* () { const result = yield* readWithLogs( FileSystem.layerNoop({ @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "decode-document"); assert.equal(result.records.length, 1); const message = result.records[0]?.message; if (!Array.isArray(message)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 0d9ddc8fde91..9fbacc832a90 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -44,6 +44,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + onboardingCompletedAt: null, panelAnimationDurationMs: 0, planModeEnabled: false, proactivePanelsEnabled: true, @@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => { ), ); + for (const failure of [ + { label: "permission", reason: "PermissionDenied" }, + { label: "I/O", reason: "Unknown" }, + ] as const) { + it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); + + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); + + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); + } + it.effect("reports the failed client settings write operation and path", () => withClientSettings( Effect.gen(function* () { @@ -222,17 +276,31 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + for (const document of [ + { label: "malformed JSON", contents: "{not-json" }, + { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, + { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, + ]) { + it.effect(`reports ${document.label} without treating the settings file as absent`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); + } }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27a27..5eadd27d5454 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); -const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); -const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( - LegacyClientSettingsDocumentJson, +const decodeClientSettingsDocument = Schema.decodeEffect( + fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), ); -const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); -const decodeClientSettingsJson = (raw: string): Effect.Effect => - decodeLegacyClientSettingsDocumentJson(raw).pipe( - Effect.map((document) => document.settings), - Effect.catchTags({ - SchemaError: () => decodeClientSettingsJsonValue(raw), - }), +const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); +const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { + const document = yield* decodeClientSettingsDocument(raw); + // Select the shape before validation so invalid legacy settings cannot become defaults. + return yield* decodeClientSettingsValue( + Object.hasOwn(document, "settings") ? document.settings : document, ); +}); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); +export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass()( + "DesktopClientSettingsReadError", + { + operation: Schema.Literals(["read-file", "decode-document"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} + const DesktopClientSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>; + readonly get: Effect.Effect, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect; @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect> => +): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ @@ -74,7 +82,15 @@ const readClientSettings = ( ? Effect.succeed(Option.none()) : Effect.logWarning("Could not read desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-file", + path: settingsPath, + cause, + }), + ), + ), ), }), Effect.flatMap( @@ -87,7 +103,15 @@ const readClientSettings = ( SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "decode-document", + path: settingsPath, + cause, + }), + ), + ), ), }), ), diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..7262239577b4 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => { ); }); + it("requires write access to import agent session history", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index de6661f45886..7bd1ed6c45f1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -99,6 +99,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index c8b143f87ebc..d05ca5ec854a 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -289,6 +291,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -361,6 +364,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -418,6 +422,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index cb1725882fba..1dae23cdccb0 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1342,6 +1342,36 @@ describe("CheckpointReactor", () => { ).toBe("v1\n"); }); + it("does not create checkpoints while importing historical user messages", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); + if (runtime === null) throw new Error("Checkpoint test runtime was not initialized."); + + await runtime.runPromise( + harness.engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("cmd-import-history-without-checkpoint"), + threadId: ThreadId.make("thread-1"), + messages: [ + { + messageId: MessageId.make("imported-user-message"), + role: "user", + text: "A message from an existing agent session", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + }), + ); + await harness.drain(); + + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), + ).toBe(false); + }); + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { const harness = await createHarness({ hasSession: false, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index de1cb29c5cd9..0331b0141fb3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -615,6 +615,7 @@ const make = Effect.gen(function* () { ) { if (event.type === "thread.message-sent") { if ( + event.metadata.historyImport === true || event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 866e56ac0378..abfb49b53050 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -418,6 +418,7 @@ describe("OrchestrationEngine", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0249844864fb..d81d9b11b1f6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -108,6 +108,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( + "imported thread shell projection", + (it) => { + it.effect("does not mark imported user messages as queued work in thread shells", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("import:codex:shell-session"); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-import-shell-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-import-shell"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-import-shell-message"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-message"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:shell-session:0"), + role: "user", + text: "Imported user prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>` + SELECT latest_user_message_at AS "latestUserMessageAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + + const sessionEvent = yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-import-shell-session"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-session"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-session"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }, + }); + yield* projectionPipeline.projectEvent(sessionEvent); + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cd028d502238..b338596993d2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,10 +1,12 @@ import { ApprovalRequestId, + isImportedAgentSessionMessageId, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -248,7 +250,7 @@ function retainProjectionMessagesAfterRevert( } for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) { retainedMessageIds.add(message.messageId); continue; } @@ -258,7 +260,10 @@ function retainProjectionMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -271,7 +276,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingUserCount); @@ -281,7 +286,10 @@ function retainProjectionMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -294,7 +302,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingAssistantCount); @@ -903,6 +911,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti updatedAt: event.occurredAt, latestUserMessageAt: event.payload.role === "user" && + !isImportedAgentSessionMessageId(event.payload.messageId) && (previousLatest === null || event.payload.createdAt > previousLatest) ? event.payload.createdAt : previousLatest, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6330c38ca0c3..a1b351de3535 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,4 +1,5 @@ import { + type AgentSessionImportSource, CheckpointRef, EventId, MessageId, @@ -11,6 +12,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -2134,7 +2136,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = // // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) // and a turnless activity at T03.6 — both belong to the page containing T03+. - const seedFanOutThread = Effect.fnUntraced(function* () { + const seedFanOutThread = Effect.fnUntraced(function* (options?: { + readonly importedMessageCount?: number; + }) { const sql = yield* SqlClient.SqlClient; // Tests in this block share one in-memory database; reset before seeding. @@ -2163,6 +2167,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) `; + if (options?.importedMessageCount) { + for (let index = 0; index < options.importedMessageCount; index += 1) { + const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`; + const role = index % 2 === 0 ? "user" : "assistant"; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0, + '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z') + `; + } + } + const turns: ReadonlyArray<{ turn: string; pendingMessage: string | null; @@ -2396,6 +2414,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("keeps imported history on the oldest page after resumed turns", () => + Effect.gen(function* () { + yield* seedFanOutThread({ importedMessageCount: 12 }); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 }); + assert.equal(completePage._tag, "Some"); + if (completePage._tag !== "Some") return; + assert.equal( + completePage.value.thread.messages.filter((message) => message.id.startsWith("import:")) + .length, + 12, + ); + assert.equal(completePage.value.page?.hasMore, false); + assert.equal(completePage.value.page?.beforeCursor, null); + + const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(recentPage._tag, "Some"); + if (recentPage._tag !== "Some") return; + assert.equal( + recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")), + false, + ); + const cursor = recentPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(oldestPage._tag, "Some"); + if (oldestPage._tag !== "Some") return; + + const importedIds = oldestPage.value.thread.messages + .map((message) => message.id) + .filter((messageId) => messageId.startsWith("import:")); + assert.equal(importedIds.length, 12); + assert.equal(new Set(importedIds).size, 12); + assert.equal(oldestPage.value.page?.hasMore, false); + assert.equal(oldestPage.value.page?.beforeCursor, null); + }), + ); + it.effect("a cursor for a different thread degrades to the first page", () => Effect.gen(function* () { yield* seedFanOutThread(); @@ -2779,3 +2842,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); }); + +projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { + const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const source: AgentSessionImportSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex-home"), + providerSessionId: "native-session", + filePath: "/tmp/transcript.jsonl", + size: 128, + mtimeMs: 1_700_000_000_000, + device: 1, + inode: 2, + birthtimeMs: 1_699_000_000_000, + }; + + const seedImportedSession = Effect.fn("seedImportedSession")(function* ( + projectId: ProjectId, + source: AgentSessionImportSource, + ) { + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const timestamp = "2026-03-02T00:00:00.000Z"; + yield* sql` + INSERT OR IGNORE INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + created_at, updated_at + ) VALUES (${threadId}, ${projectId}, 'Imported thread', + ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })}, + 'full-access', 'default', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, + last_seen_at, resume_cursor_json, runtime_payload_json + ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId}, + ${source.provider}, 'full-access', 'stopped', ${timestamp}, + ${encodeJson({ threadId: source.providerSessionId })}, + ${encodeJson({ importedTranscripts: [source] })}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0, + ${timestamp}, ${timestamp}) + `; + return { threadId, source }; + }); + + it.effect("reads completed source copies without decoding message bodies", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-metadata"); + const imported = yield* seedImportedSession(projectId, source); + const copiedSource = { + ...source, + filePath: "/tmp/transcript-copy.jsonl", + mtimeMs: null, + inode: null, + birthtimeMs: null, + }; + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + cwd: "/tmp/imported-project", + importedTranscripts: [source, copiedSource], + })} + WHERE thread_id = ${imported.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET attachments_json = 'not-json' + WHERE thread_id = ${imported.threadId} + `; + + const counter = makeSqlStatementCounter(); + const sources = yield* query + .getImportedAgentSessionSources(projectId) + .pipe(Effect.withTracer(counter.tracer)); + assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]); + assert.equal(counter.count(), 1); + }), + ); + + it.effect("requires active project threads, a binding, and an imported message", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-completion"); + const completed = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "completed", + }); + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`} + WHERE thread_id = ${completed.threadId} + `; + const partials = yield* Effect.forEach( + [ + "no-binding", + "no-history", + "no-imported-message", + "wrong-message-thread", + "archived", + "deleted", + ], + (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }), + ); + const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] = + partials; + assert.isDefined(noBinding); + assert.isDefined(noHistory); + assert.isDefined(noImportedMessage); + assert.isDefined(wrongMessageThread); + assert.isDefined(archived); + assert.isDefined(deleted); + yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`; + yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`; + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`} + WHERE thread_id = ${noImportedMessage.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET thread_id = 'unrelated-thread' + WHERE thread_id = ${wrongMessageThread.threadId} + `; + yield* sql` + UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${archived.threadId} + `; + yield* sql` + UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${deleted.threadId} + `; + const otherProjectId = ProjectId.make("project-import-other"); + const otherProject = yield* seedImportedSession(otherProjectId, { + ...source, + providerSessionId: "other-project", + }); + const deletedProjectId = ProjectId.make("project-import-deleted"); + yield* seedImportedSession(deletedProjectId, { + ...source, + providerSessionId: "deleted-project", + }); + yield* sql` + UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE project_id = ${deletedProjectId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []); + assert.deepEqual( + yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")), + [], + ); + }), + ); + + it.effect("keeps original sources when the current runtime provider and cursor change", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-switched"); + const imported = yield* seedImportedSession(projectId, { + ...source, + provider: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claude-original"), + providerSessionId: "original-session", + }); + yield* sql` + UPDATE provider_session_runtime + SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex', + resume_cursor_json = '{"threadId":"new-session"}' + WHERE thread_id = ${imported.threadId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]); + }), + ); + + it.effect("skips invalid source payloads and entries without dropping valid sources", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-invalid"); + const imported = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "a-invalid", + }); + const valid = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "z-valid", + }); + for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) { + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = ${payload} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + } + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = X'FF' + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + importedTranscripts: [ + null, + {}, + { ...imported.source, size: -1 }, + { ...imported.source, provider: "cursor" }, + { ...imported.source, providerInstanceId: "wrong-instance" }, + { ...imported.source, providerSessionId: "wrong-session" }, + imported.source, + ], + })} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 91d09bf57193..86e0b94573d9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + AgentSessionImportSource, ApprovalRequestId, ChatAttachment, CheckpointRef, @@ -75,6 +76,14 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + importedTranscripts: Schema.Array(Schema.Unknown), + }), + ), +); +const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource); // Keep detail reads consistent with the in-memory projector's retained // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. @@ -164,6 +173,10 @@ const WorkspaceRootLookupInput = Schema.Struct({ const ProjectIdLookupInput = Schema.Struct({ projectId: ProjectId, }); +const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ + threadId: ThreadId, + runtimePayload: Schema.Unknown, +}); const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); @@ -983,6 +996,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listImportedAgentSessionSourceRows = SqlSchema.findAll({ + Request: ProjectIdLookupInput, + Result: ProjectionImportedAgentSessionSourcesRowSchema, + execute: ({ projectId }) => + sql` + SELECT + threads.thread_id AS "threadId", + runtime.runtime_payload_json AS "runtimePayload" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + INNER JOIN provider_session_runtime AS runtime + ON runtime.thread_id = threads.thread_id + WHERE threads.project_id = ${projectId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_messages AS messages + WHERE messages.thread_id = threads.thread_id + AND messages.message_id GLOB 'import:*' + ) + ORDER BY threads.thread_id ASC + `, + }); + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadCheckpointContextThreadRowSchema, @@ -2663,6 +2703,33 @@ pending_approval_requests AS ( Effect.map(Option.map((row) => row.threadId)), ); + const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] = + Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) { + const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getImportedAgentSessionSources:query", + "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows", + ), + ), + ); + return rows.flatMap((row) => { + const payload = decodeImportedTranscriptsPayload(row.runtimePayload); + if (Option.isNone(payload)) return []; + return payload.value.importedTranscripts.flatMap((entry) => { + const source = decodeAgentSessionImportSource(entry); + if ( + Option.isNone(source) || + row.threadId !== + `import:${source.value.providerInstanceId}:${source.value.providerSessionId}` + ) { + return []; + } + return [{ threadId: row.threadId, source: source.value }]; + }); + }); + }); + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( threadId, ) => @@ -3151,17 +3218,35 @@ pending_approval_requests AS ( ); const oldest = windowRows[0]; + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; // An empty window (no turns before the cursor, or a thread with no // turns at all) still returns thread metadata with empty collections // for turn-linked rows; turnless rows are bounded to the same empty // range. The first page of a turnless thread stays unwindowed so - // pre-turn content (e.g. a just-created thread) is not hidden. + // pre-turn content (e.g. a just-created thread) is not hidden. Once + // paging reaches the oldest turn, include turnless messages before + // the first turn, such as history imported from a provider session. const bounds: ThreadDetailBounds | undefined = oldest === undefined && cursor === null ? undefined : { - minAnchorAt: oldest?.anchorAt ?? "", - minTurnKey: oldest?.turnKey ?? "", + minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "", + minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "", beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, beforeTurnKey: cursor?.beforeTurnId ?? "", }; @@ -3178,23 +3263,6 @@ pending_approval_requests AS ( return Option.none(); } - const hasMore = - oldest !== undefined && - (yield* listTurnWindowRows({ - threadId, - beforeAnchorAt: oldest.anchorAt, - beforeTurnKey: oldest.turnKey, - userTurnLimit: 1, - maxRawTurns: 1, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", - ), - ), - )).length > 0; - const { snapshotSequence } = yield* getSnapshotSequence(); const watermarkRow = yield* getThreadEventWatermarkRow({ threadId, @@ -3253,6 +3321,7 @@ pending_approval_requests AS ( getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, + getImportedAgentSessionSources, getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 96072b266319..37bcc1ae8f39 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ * @module ProjectionSnapshotQuery */ import type { + AgentSessionImportSource, ApprovalRequestId, CheckpointRef, OrchestrationCheckpointSummary, @@ -170,6 +171,15 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read completed import sources without loading thread history. */ + readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect< + ReadonlyArray<{ + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }>, + ProjectionRepositoryError + >; + /** * Read the checkpoint context needed to resolve a single thread diff. */ diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts new file mode 100644 index 000000000000..c809c733800a --- /dev/null +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -0,0 +1,509 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +it.layer(NodeServices.layer)("thread history import", (it) => { + it.effect("marks imported thread creation without changing live creation", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const projectId = ProjectId.make("project-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-project-created"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: createdAt, + commandId: CommandId.make("command-project-created"), + causationEventId: null, + correlationId: CommandId.make("command-project-created"), + metadata: {}, + payload: { + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + const makeCreateCommand = (threadId: ThreadId) => ({ + type: "thread.create" as const, + commandId: CommandId.make(`command-create-${threadId}`), + threadId, + projectId, + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt, + }); + + const imported = yield* decideOrchestrationCommand({ + command: { + ...makeCreateCommand(ThreadId.make("import:codex:session-1")), + historyImport: true, + }, + readModel, + }); + const live = yield* decideOrchestrationCommand({ + command: makeCreateCommand(ThreadId.make("live-thread")), + readModel, + }); + + expect(imported).toMatchObject({ + type: "thread.created", + metadata: { historyImport: true }, + }); + expect(live).toMatchObject({ type: "thread.created" }); + expect(live).not.toMatchObject({ metadata: { historyImport: true } }); + }), + ); + + it.effect("settles imported messages at the latest absolute timestamp", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:30:00.000+02:00"; + const threadId = ThreadId.make("import:codex:session-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const events = yield* decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-import-history"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt, + }, + { + messageId: MessageId.make(`${threadId}:000001`), + role: "assistant", + text: "Fixed", + createdAt: "2026-08-24T09:00:00.000Z", + }, + ], + }, + readModel, + }); + + expect(events).toMatchObject([ + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false }, + }, + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false }, + }, + { + type: "thread.settled", + metadata: { historyImport: true }, + occurredAt: "2026-08-24T09:00:00.000Z", + payload: { + settledAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", + }, + }, + ]); + + let projected = readModel; + const plannedEvents = Array.isArray(events) ? events : [events]; + for (const [index, event] of plannedEvents.entries()) { + projected = yield* projectEvent(projected, { ...event, sequence: index + 2 }); + } + projected = yield* projectEvent(projected, { + sequence: 5, + eventId: EventId.make("event-import-reverted"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.reverted", + occurredAt: "2026-08-24T10:02:00.000Z", + commandId: CommandId.make("command-import-reverted"), + causationEventId: null, + correlationId: CommandId.make("command-import-reverted"), + metadata: {}, + payload: { threadId, turnCount: 0 }, + }); + expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([ + "Fix the bug", + "Fixed", + ]); + }), + ); + + it.effect("allows a thread with a newly imported user message to be settled", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z")); + const threadId = ThreadId.make("import:codex:session-1"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-import-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-import-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-import-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-import-user-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: createdAt, + commandId: CommandId.make("command-import-user-message"), + causationEventId: null, + correlationId: CommandId.make("command-import-user-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:session-1:0"), + role: "user", + text: "Existing prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("command-settle-imported-thread"), + threadId, + }, + readModel, + }); + + expect(result).toMatchObject({ type: "thread.settled" }); + }), + ); + + it.effect("rejects history import after a client message reaches the thread", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const liveMessageAt = "2026-08-24T10:02:00.000Z"; + const threadId = ThreadId.make("import:codex:client-race"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-client-race-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-client-race-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-client-race-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: liveMessageAt, + commandId: CommandId.make("command-client-race-message"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-message"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("client-race-message"), + role: "user", + text: "Start live work", + turnId: null, + streaming: false, + createdAt: liveMessageAt, + updatedAt: liveMessageAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-client-race-import"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt); + }), + ); + + for (const requestKind of ["approval.requested", "user-input.requested"] as const) { + it.effect(`rejects history import with an open ${requestKind} activity`, () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make(`import:codex:${requestKind}`); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make(`event-${requestKind}-thread-created`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}-thread-created`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}-thread-created`), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make(`event-${requestKind}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.activity-appended", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}`), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make(`activity-${requestKind}`), + tone: "approval", + kind: requestKind, + summary: "Pending request", + payload: { requestId: "request-1" }, + turnId: null, + createdAt, + }, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make(`command-import-${requestKind}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + }), + ); + } + + it.effect("rejects a live user message in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("command-live-import-id"), + threadId, + message: { + messageId: MessageId.make("import:forged-live-message"), + role: "user", + text: "Live work", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + }), + ); + + it.effect("rejects live assistant messages in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-assistant-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-assistant-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-assistant-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-assistant-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + for (const commandType of [ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + ] as const) { + const command = + commandType === "thread.message.assistant.delta" + ? { + type: commandType, + commandId: CommandId.make("command-live-assistant-delta-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + delta: "Live work", + createdAt, + } + : { + type: commandType, + commandId: CommandId.make("command-live-assistant-complete-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + createdAt, + }; + const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel })); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b336053ac9e1..1fd9feba7c44 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2,12 +2,14 @@ import { EventId, MessageId, UserInputRequestedPayload, + isImportedAgentSessionMessageId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, type OrchestrationThreadActivity, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -98,7 +100,7 @@ function hasQueuedTurnStartForThread( let latestUserMessageAt: string | null = null; let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; for (const message of thread.messages) { - if (message.role !== "user") continue; + if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue; const messageAtMs = Date.parse(message.createdAt); latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); if (messageAtMs === latestUserMessageAtMs) { @@ -347,6 +349,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, + ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}), })), type: "thread.created", payload: { @@ -905,6 +908,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.turn.start": { + if (isImportedAgentSessionMessageId(command.message.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, + }); + } const targetThread = yield* requireThread({ readModel, command, @@ -1272,6 +1281,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.delta": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1299,6 +1314,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.complete": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1325,6 +1346,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.history.import": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.messages.length > 0 || + thread.latestTurn !== null || + thread.session !== null || + hasOpenBlockingRequest(thread) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`, + }); + } + const firstMessage = command.messages[0]; + if (firstMessage === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Thread history imports require at least one message.", + }); + } + + const events: Array = []; + for (const message of command.messages) { + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: message.createdAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: message.messageId, + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }, + }); + } + const settledAt = command.messages.reduce( + (latest, message) => + compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest, + firstMessage.createdAt, + ); + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: settledAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: settledAt, + }, + }); + return events; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index a558e0ad7af8..77dbe51b9542 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,10 +1,12 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + isImportedAgentSessionMessageId, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; @@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert( ): ReadonlyArray { const retainedMessageIds = new Set(); for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { retainedMessageIds.add(message.id); continue; } @@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingUserCount); for (const message of fallbackUserMessages) { @@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingAssistantCount); for (const message of fallbackAssistantMessages) { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index c8fa16158bae..d4a70af59be5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,12 +12,24 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { - it.effect("finds the latest user-message time within one thread", () => + it.effect("finds the latest live user-message time within one thread", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-latest-user-message"); assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + yield* repository.upsert({ + messageId: MessageId.make("import:codex:latest-user-message:000000"), + threadId, + turnId: null, + role: "user", + text: "Imported prompt", + isStreaming: false, + createdAt: "2026-02-28T19:05:06.000Z", + updatedAt: "2026-02-28T19:05:06.000Z", + }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + const messages = [ { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index ce28e11b8601..be20fb37f36d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -191,6 +191,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { SELECT MAX(created_at) AS "latestUserMessageAt" FROM projection_thread_messages WHERE thread_id = ${threadId} AND role = 'user' + AND message_id NOT GLOB 'import:*' `, }); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2ccdd862522f..d73f56aab9e0 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { + AgentSessionImportSource, IsoDateTime, ProviderInstanceId, ProviderSessionRuntimeStatus, @@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; +export const RecordImportedTranscriptInput = Schema.Struct({ + threadId: ThreadId, + source: AgentSessionImportSource, +}); +export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type; + +export interface ProviderSessionRuntimeUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + /** * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. */ @@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service< /** * Insert or replace a provider runtime row. * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. + * Upserts by canonical `threadId`, retaining imported transcript records + * from the current database row. */ readonly upsert: ( runtime: ProviderSessionRuntime, + options?: ProviderSessionRuntimeUpsertOptions, + ) => Effect.Effect; + + /** Record one source file without replacing the current session state. */ + readonly recordImportedTranscript: ( + input: RecordImportedTranscriptInput, ) => Effect.Effect; /** @@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; +const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields( + Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }), +); + function toPersistenceSqlOrDecodeError( sqlOperation: string, decodeOperation: string, @@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Runtime writes can carry stale payloads. Only recordImportedTranscript may + // change source records, so restore that field from the row being updated. const upsertRuntimeRow = SqlSchema.void({ Request: ProviderSessionRuntimeDbRowSchema, execute: (runtime) => @@ -171,7 +195,11 @@ export const make = Effect.gen(function* () { ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, - ${runtime.runtimePayload} + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END ) ON CONFLICT (thread_id) DO UPDATE SET @@ -182,7 +210,107 @@ export const make = Effect.gen(function* () { status = excluded.status, last_seen_at = excluded.last_seen_at, resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json + runtime_payload_json = CASE + WHEN json_type( + CASE + WHEN json_valid(provider_session_runtime.runtime_payload_json) + THEN provider_session_runtime.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts' + ) IS NOT NULL + THEN json_set( + CASE + WHEN json_type(excluded.runtime_payload_json) = 'object' + THEN excluded.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts', + json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts') + ) + ELSE excluded.runtime_payload_json + END + `, + }); + + const insertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.providerInstanceId}, + ${runtime.adapterKey}, + ${runtime.runtimeMode}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END + ) + ON CONFLICT (thread_id) DO NOTHING + `, + }); + + const recordImportedTranscriptRow = SqlSchema.void({ + Request: RecordImportedTranscriptRequestSchema, + execute: ({ threadId, source }) => + sql` + WITH current_runtime AS ( + SELECT CASE + WHEN json_valid(runtime_payload_json) THEN CASE + WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json + ELSE '{}' + END + ELSE '{}' + END AS payload + FROM provider_session_runtime + WHERE thread_id = ${threadId} + ) + UPDATE provider_session_runtime + SET runtime_payload_json = ( + SELECT json_set( + payload, + '$.importedTranscripts', + json(( + SELECT json_group_array(json(value)) + FROM ( + SELECT value + FROM json_each(CASE + WHEN json_type(payload, '$.importedTranscripts') = 'array' + THEN json_extract(payload, '$.importedTranscripts') + ELSE '[]' + END) + WHERE CASE + WHEN type = 'object' THEN + json_extract(value, '$.providerInstanceId') + IS NOT json_extract(${source}, '$.providerInstanceId') + OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath') + ELSE 0 + END + UNION ALL + SELECT ${source} AS value + ) + )) + ) + FROM current_runtime + ) + WHERE thread_id = ${threadId} `, }); @@ -235,8 +363,8 @@ export const make = Effect.gen(function* () { `, }); - const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) => + (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", @@ -246,6 +374,18 @@ export const make = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] = + (input) => + recordImportedTranscriptRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.recordImportedTranscript:query", + "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest", + { threadId: input.threadId }, + ), + ), + ); + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => getRuntimeRowByThreadId(input).pipe( Effect.mapError( @@ -324,6 +464,7 @@ export const make = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getByThreadId, list, deleteByThreadId, diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts new file mode 100644 index 000000000000..38d6ad1d4331 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -0,0 +1,1213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import { + AgentSessionImportProjectChangedError, + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThread, + type ProviderSendTurnInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; +import { ServerConfig } from "../config.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../provider/Layers/ProviderEventLoggers.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts"; +import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; +import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const WORKSPACE_ROOT = "/tmp/project-from-server"; +const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({ + source, + providerInstanceId: ProviderInstanceId.make(source), + providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID, + title: `Imported ${source} thread`, + model: null, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:01:00.000Z", + messages: [ + { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" }, + { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" }, + ], +}); + +const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) => + ({ + _tag: "Importable", + thread, + source: { + provider: thread.source, + providerInstanceId: thread.providerInstanceId, + providerSessionId: thread.providerSessionId, + filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`, + size: 0, + mtimeMs: 0, + device: 0, + inode: 0, + birthtimeMs: 0, + }, + }) satisfies AgentSessionScanner.AgentSessionRecentThread; + +const makeProject = (): OrchestrationProjectShell => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", +}); + +const makeProjectedThread = (input: { + readonly source: "codex" | "claudeAgent"; + readonly projectId?: ProjectId; + readonly imported?: boolean; + readonly includeFollowup?: boolean; +}): OrchestrationThread => { + const sourceThread = makeThread(input.source); + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + return { + id: threadId, + projectId: input.projectId ?? PROJECT_ID, + title: sourceThread.title, + modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: sourceThread.createdAt, + updatedAt: sourceThread.updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: input.imported + ? [ + { + id: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:00:00.000Z", + }, + ...(input.includeFollowup + ? [ + { + id: MessageId.make("user-followup"), + role: "user" as const, + text: "Keep going", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:02:00.000Z", + updatedAt: "2026-08-24T10:02:00.000Z", + }, + ] + : []), + ] + : [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }; +}; + +const makeSnapshotsLayer = (input: { + readonly project?: OrchestrationProjectShell; + readonly getThread?: (threadId: ThreadId) => Option.Option; +}) => + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getProjectShellById: () => + Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)), + getImportedAgentSessionSources: () => Effect.succeed([]), + getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()), + }); + +const runImport = (input: { + readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; + readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"]; + readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly snapshots: ReturnType; + readonly expectedWorkspaceRoot?: string; +}) => + importRecentAgentThreads({ + projectId: PROJECT_ID, + ...(input.expectedWorkspaceRoot === undefined + ? {} + : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }), + }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), + Effect.provide(input.snapshots), + ); + +it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { + describe("importRecentAgentThreads", () => { + it.effect("uses the project root and stores provider-specific resume cursors", () => + Effect.gen(function* () { + const commands: Array = []; + const bindings: Array = []; + let scannedRoot: string | undefined; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: (workspaceRoot) => { + scannedRoot = workspaceRoot; + return Stream.concat( + Stream.succeed(makeThreadOutcome(makeThread("codex"))), + Stream.fromEffect( + Effect.sync(() => { + expect(bindings).toHaveLength(1); + return makeThreadOutcome(makeThread("claudeAgent")); + }), + ), + ); + }, + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => Effect.sync(() => void bindings.push(binding)), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`, + }); + + expect(result).toEqual({ importedCount: 2, skippedCount: 0 }); + expect(scannedRoot).toBe(WORKSPACE_ROOT); + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.history.import", + "thread.create", + "thread.history.import", + ]); + expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([ + { historyImport: true }, + { historyImport: true }, + ]); + expect( + commands + .filter((command) => command.type === "thread.history.import") + .flatMap((command) => command.messages.map((message) => message.messageId)), + ).toEqual([ + "import:codex:codex-session:000000", + "import:codex:codex-session:000001", + `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`, + `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`, + ]); + expect(bindings).toMatchObject([ + { + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + { + provider: "claudeAgent", + providerInstanceId: "claudeAgent", + resumeCursor: { + threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`, + resume: CLAUDE_SESSION_ID, + }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + ]); + }), + ); + + it.effect("rejects a changed project root before scanning or writing", () => + Effect.gen(function* () { + const recentThreads = vi.fn(() => Stream.empty); + const error = yield* importRecentAgentThreads({ + projectId: PROJECT_ID, + expectedWorkspaceRoot: WORKSPACE_ROOT, + }).pipe( + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("must not scan a changed project"), + recentThreads, + }), + ), + Effect.provide( + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}), + makeSnapshotsLayer({ + project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" }, + }), + ), + ), + Effect.flip, + ); + + expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID })); + expect(recentThreads).not.toHaveBeenCalled(); + }), + ); + + it.effect("counts scanner skips without writing a thread or binding", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed({ _tag: "Skipped" }), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not dispatch for a scanner skip"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind a scanner skip"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.die("must not read a scanner skip binding"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 1 }); + }), + ); + + it.effect("recovers after a rejected history receipt and a failed binding write", () => + Effect.gen(function* () { + let threadCreated = false; + let historyImported = false; + let historyAttemptCount = 0; + let bindingAttemptCount = 0; + const rejectedCommandIds = new Set(); + const bindings: Array = []; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => { + if (rejectedCommandIds.has(command.commandId)) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Previously rejected.", + }), + ); + } + if (command.type === "thread.create") threadCreated = true; + if (command.type === "thread.history.import") { + historyAttemptCount += 1; + if (historyAttemptCount === 1) { + rejectedCommandIds.add(command.commandId); + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Temporary history import failure.", + }), + ); + } + historyImported = true; + } + return Effect.succeed({ sequence: 1 }); + }, + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => { + bindingAttemptCount += 1; + if (bindingAttemptCount === 1) { + return Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "upsert", + detail: "Temporary session storage failure.", + }), + ); + } + bindings.push(binding); + return Effect.void; + }, + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => + Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const snapshots = makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + threadCreated + ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported })) + : Option.none(), + }); + const importOnce = () => runImport({ scanner, engine, directory, snapshots }); + + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + const historyAttemptsAfterCompletion = historyAttemptCount; + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion); + expect(historyAttemptCount).toBe(2); + expect(bindings).toHaveLength(1); + }), + ); + + it.effect("does not replace completed history or an active binding on retry", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: ThreadId.make("import:codex:codex-session"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "newer-codex-session" }, + }; + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not replace an active binding"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.some(runningBinding)), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not replay history or settle active work"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + Option.some( + makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }), + ), + }), + }); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + }), + ); + + it.effect("skips malformed Claude ids and wrong-project thread collisions", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.fromIterable([ + makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }), + makeThreadOutcome(makeThread("codex")), + ]), + }); + const commands: Array = []; + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: (threadId) => + threadId === "import:codex:codex-session" + ? Option.some( + makeProjectedThread({ + source: "codex", + projectId: ProjectId.make("project-other"), + }), + ) + : Option.none(), + }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 2 }); + expect(commands).toHaveLength(0); + }), + ); + }); +}); + +const integrationThread = { + ...makeThread("codex"), + updatedAt: "2026-08-24T10:00:00.000Z", + messages: Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: `Message ${index}`, + createdAt: "2026-08-24T10:00:00.000Z", + })), +}; +const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]), +}); +const integrationServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-agent-session-importer-test-", +}); +const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), +); +const integrationLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, + integrationRuntimeRepository, + ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)), + Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner), +).pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(integrationServerConfig), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { + it.effect("imports once after the real engine persists an old rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:codex-session"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-integration-project"), + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + const rejected = yield* Effect.result( + engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(`agent-session:history:${threadId}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }), + ); + expect(rejected._tag).toBe("Failure"); + + const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID }); + const importedThread = yield* snapshots.getThreadDetailById(threadId); + const binding = yield* directory.getBinding(threadId); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled"); + expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z"); + expect(Option.getOrThrow(binding)).toMatchObject({ + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }); + + yield* engine.dispatch({ + type: "thread.revert.complete", + commandId: CommandId.make("revert-imported-thread-to-baseline"), + threadId, + turnCount: 0, + createdAt: "2026-08-24T10:05:00.000Z", + }); + const afterRevert = yield* snapshots.getThreadDetailById(threadId); + expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + }), + ); + + it.effect( + "retries a bounded import after scanner restart without rereading completed transcripts", + () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-import-retry-", + }); + const workspaceRoot = path.join(fixtureDir, "workspace"); + const claudeHomePath = path.join(fixtureDir, "claude"); + const codexHomePath = path.join(fixtureDir, "codex"); + const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* fileSystem.makeDirectory(workspaceRoot); + yield* fileSystem.makeDirectory(claudeHomePath); + yield* fileSystem.makeDirectory(sessionsDir, { recursive: true }); + + const projectId = ProjectId.make("project-bounded-import-retry"); + const transcripts = Array.from({ length: 101 }, (_, index) => { + const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`; + return { + providerSessionId, + threadId: ThreadId.make(`import:codex:${providerSessionId}`), + filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`), + }; + }); + for (const [index, transcript] of transcripts.entries()) { + yield* fileSystem.writeFileString( + transcript.filePath, + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: transcript.providerSessionId, cwd: workspaceRoot }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: `Prompt ${transcript.providerSessionId}`, + }, + }), + ].join("\n"), + ); + const seconds = nowMs / 1_000 - index; + yield* fileSystem.utimes(transcript.filePath, seconds, seconds); + } + const legacy = transcripts[0]!; + const failed = transcripts[1]!; + const remaining = transcripts[100]!; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-bounded-import-project"), + projectId, + title: "Bounded import", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + // This completed import predates persisted transcript source metadata. + yield* directory.upsert({ + threadId: legacy.threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "legacy-current-session" }, + runtimePayload: { cwd: workspaceRoot }, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-legacy-bounded-import"), + threadId: legacy.threadId, + projectId, + title: "Legacy import", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + historyImport: true, + }); + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("import-legacy-bounded-history"), + threadId: legacy.threadId, + messages: [ + { + messageId: MessageId.make(`${legacy.threadId}:000000`), + role: "user", + text: "Legacy imported history", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }); + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]); + + let failHistory = true; + const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({ + ...engine, + dispatch: (command) => { + if ( + failHistory && + command.type === "thread.history.import" && + command.threadId === failed.threadId + ) { + failHistory = false; + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Injected history import failure.", + }), + ); + } + return engine.dispatch(command); + }, + }); + const settingsLayer = ServerSettingsService.layerTest({ + providers: { + claudeAgent: { homePath: claudeHomePath }, + codex: { homePath: codexHomePath }, + }, + }); + const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath)); + const runAttempt = Effect.fn("runBoundedImportAttempt")(function* ( + completedPaths: ReadonlySet, + ) { + const openCounts = new Map(); + const fullReads: string[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => + Effect.suspend(() => { + if (transcriptPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + // A fresh scanner first opens each file for project discovery. + if (count > 1) { + fullReads.push(filePath); + if (completedPaths.has(filePath)) { + return Effect.die(new Error(`Completed transcript reopened: ${filePath}`)); + } + } + } + return fileSystem.open(filePath, options); + }), + }); + const result = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provide( + Layer.fresh(AgentSessionScanner.layer).pipe( + Layer.provide(settingsLayer), + Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)), + ), + ), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine), + ); + return { result, fullReads, openCounts }; + }); + + const first = yield* runAttempt(new Set()); + expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 }); + expect(failHistory).toBe(false); + expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath)); + expect(first.openCounts.get(remaining.filePath)).toBe(1); + const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId); + expect(completedSources).toHaveLength(99); + expect(completedSources).toContainEqual({ + threadId: legacy.threadId, + source: expect.objectContaining({ filePath: legacy.filePath }), + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages, + ).toEqual([]); + expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({ + status: "stopped", + resumeCursor: { threadId: failed.providerSessionId }, + }); + expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true); + + const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath)); + const second = yield* runAttempt(completedPaths); + expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 }); + expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]); + for (const transcript of transcripts) { + expect(second.openCounts.get(transcript.filePath)).toBe( + completedPaths.has(transcript.filePath) ? 1 : 2, + ); + } + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Legacy imported history"]); + expect( + Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor, + ).toEqual({ + threadId: "legacy-current-session", + }); + for (const transcript of [failed, remaining]) { + expect( + Option.getOrThrow( + yield* snapshots.getThreadDetailById(transcript.threadId), + ).messages.map((message) => message.text), + ).toEqual([`Prompt ${transcript.providerSessionId}`]); + } + }), + ); + + for (const source of ["codex", "claudeAgent"] as const) { + it.effect(`resumes imported ${source} history only after the first prompt`, () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped(); + const projectId = ProjectId.make(`project-import-resume-${source}`); + const sourceThread = { + ...makeThread(source), + providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID, + }; + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + const resumeCursor = + source === "codex" + ? { threadId: sourceThread.providerSessionId } + : { threadId, resume: sourceThread.providerSessionId }; + const provider = ProviderDriverKind.make(source); + const harness = yield* makeTestProviderAdapterHarness({ provider }); + const importSettled = yield* Deferred.make(); + const turnSent = yield* Deferred.make(); + const startSession = vi.fn(harness.adapter.startSession); + const sendTurn = vi.fn((input: ProviderSendTurnInput) => + harness.adapter + .sendTurn(input) + .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry, + makeAdapterRegistryMock({ + [provider]: { ...harness.adapter, startSession, sendTurn }, + }), + ), + ), + Layer.provide( + Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), + ), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide(AnalyticsService.layerTest), + ); + const reactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(providerLayer), + Layer.provide( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + ...snapshots, + // Acknowledge the imported settlement before draining the reactor. + getThreadShellById: (requestedThreadId) => + snapshots + .getThreadShellById(requestedThreadId) + .pipe( + Effect.tap(() => + requestedThreadId === threadId + ? Deferred.succeed(importSettled, undefined) + : Effect.void, + ), + ), + }), + ), + Layer.provide( + Layer.mock(ProviderAuthService)({ + tryHandlePromptCommand: () => Effect.succeed(false), + }), + ), + Layer.provide(makeProviderRegistryLayer()), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), + Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(ServerSettingsService.layerTest()), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`create-import-resume-project-${source}`), + projectId, + title: "Import resume", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* harness.queueTurnResponseForNextSession({ events: [] }); + + yield* Effect.gen(function* () { + const reactor = yield* ProviderCommandReactor; + yield* reactor.start(); + expect(yield* importRecentAgentThreads({ projectId })).toEqual({ + importedCount: 1, + skippedCount: 0, + }); + yield* Deferred.await(importSettled); + yield* reactor.drain; + expect(startSession).not.toHaveBeenCalled(); + expect(sendTurn).not.toHaveBeenCalled(); + const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)); + expect(importedThread.session).toBeNull(); + expect(importedThread.latestTurn).toBeNull(); + + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`resume-imported-${source}`), + threadId, + message: { + messageId: MessageId.make(`resume-imported-message-${source}`), + role: "user", + text: "Continue this session", + attachments: [], + }, + modelSelection: importedThread.modelSelection, + runtimeMode: importedThread.runtimeMode, + interactionMode: importedThread.interactionMode, + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.await(turnSent); + yield* reactor.drain; + expect(startSession).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + threadId, + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + cwd: workspaceRoot, + }), + ); + expect(sendTurn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ threadId, input: "Continue this session" }), + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + }); + }).pipe( + Effect.provide(reactorLayer), + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)), + }), + ), + ); + }), + ); + } + + it.effect("persists the resume cursor before publishing a new imported thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-binding-race"); + const workspaceRoot = "/tmp/project-import-binding-race"; + const providerSessionId = "codex-binding-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-binding-race-project"), + projectId, + title: "Binding race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + + yield* Effect.raceFirst( + Deferred.await(importerAtBindingWrite), + Fiber.join(importFiber).pipe( + Effect.flatMap((result) => + Effect.die( + new Error(`Import completed before the binding write: ${JSON.stringify(result)}`), + ), + ), + ), + ); + expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(integrationThread.messages.map((message) => message.text)); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + }), + ); + + it.effect("does not import history over a turn started on a partial thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-turn-race"); + const workspaceRoot = "/tmp/project-import-turn-race"; + const providerSessionId = "codex-turn-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-turn-race-project"), + projectId, + title: "Turn race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-import-turn-race-thread"), + threadId, + projectId, + title: "Turn race", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + yield* Deferred.await(importerAtBindingWrite); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("start-turn-during-import"), + threadId, + message: { + messageId: MessageId.make("message-during-import"), + role: "user", + text: "Continue while import waits", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Continue while import waits"]); + }), + ); +}); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts new file mode 100644 index 000000000000..3820c2cf411a --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -0,0 +1,297 @@ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionSource, + AgentSessionScanError, + isImportedAgentSessionMessageId, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, + type AgentSessionImportInput, + type AgentSessionImportResult, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const CLAUDE_SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass()( + "AgentSessionUnresumableSessionError", + { + source: AgentSessionSource, + providerSessionId: Schema.String, + }, +) { + override get message(): string { + return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`; + } +} + +class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass()( + "AgentSessionThreadProjectConflictError", + { + threadId: ThreadId, + expectedProjectId: ProjectId, + actualProjectId: ProjectId, + }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`; + } +} + +class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass()( + "AgentSessionThreadModifiedError", + { threadId: ThreadId }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' changed before its history import completed.`; + } +} + +function hasImportedHistory(thread: OrchestrationThread): boolean { + return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id)); +} + +function hasImportBlockingActivity( + thread: OrchestrationThread, + importedHistoryPresent: boolean, +): boolean { + return ( + thread.archivedAt !== null || + thread.deletedAt !== null || + thread.latestTurn !== null || + thread.session !== null || + thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) || + thread.proposedPlans.length > 0 || + thread.activities.length > 0 || + thread.checkpoints.length > 0 || + thread.snoozedUntil != null || + thread.snoozedAt != null || + thread.pinnedAt != null || + thread.pinOrderKey != null || + thread.titleRegeneration != null || + thread.linkedPullRequest != null || + thread.unsettledAt != null || + (importedHistoryPresent + ? thread.settledOverride !== "settled" + : thread.settledOverride !== null || thread.settledAt !== null) + ); +} + +/** Import recent transcript text and persist the cursor needed to resume its provider session. */ +export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( + input: AgentSessionImportInput, +) { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const crypto = yield* Crypto.Crypto; + const project = yield* snapshots.getProjectShellById(input.projectId).pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), + onSome: Effect.succeed, + }), + ), + ); + const workspaceRoot = project.workspaceRoot; + if ( + input.expectedWorkspaceRoot !== undefined && + normalizeProjectPathForComparison(workspaceRoot) !== + normalizeProjectPathForComparison(input.expectedWorkspaceRoot) + ) { + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + } + const completedSources = yield* snapshots + .getImportedAgentSessionSources(input.projectId) + .pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + const threads = scanner.recentThreads( + workspaceRoot, + completedSources.map((entry) => entry.source), + ); + const importedThreadIds = new Set(); + let importedCount = 0; + let skippedCount = 0; + + yield* Stream.runForEach(threads, (outcome) => + Effect.gen(function* () { + if (outcome._tag === "Skipped") { + skippedCount += 1; + return; + } + if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") { + const threadId = ThreadId.make( + `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`, + ); + if (outcome._tag === "AlreadyImported") { + importedThreadIds.add(threadId); + importedCount += 1; + } else if (importedThreadIds.has(threadId)) { + const recorded = yield* directory + .recordImportedTranscript({ threadId, source: outcome.source }) + .pipe(Effect.result); + if (recorded._tag === "Failure") { + skippedCount += 1; + yield* Effect.logWarning("Could not record an imported transcript copy", { + threadId, + cause: recorded.failure, + }); + } + } + return; + } + const thread = outcome.thread; + const threadId = ThreadId.make( + `import:${thread.providerInstanceId}:${thread.providerSessionId}`, + ); + const imported = yield* Effect.gen(function* () { + const provider = ProviderDriverKind.make(thread.source); + const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + const existingThread = yield* snapshots.getThreadDetailById(threadId); + const existingBinding = yield* directory.getBinding(threadId); + + if ( + thread.source === "claudeAgent" && + !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId) + ) { + return yield* new AgentSessionUnresumableSessionError({ + source: thread.source, + providerSessionId: thread.providerSessionId, + }); + } + + if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) { + return yield* new AgentSessionThreadProjectConflictError({ + threadId, + expectedProjectId: input.projectId, + actualProjectId: existingThread.value.projectId, + }); + } + + const importedHistoryPresent = Option.isSome(existingThread) + ? hasImportedHistory(existingThread.value) + : false; + if ( + Option.isSome(existingThread) && + importedHistoryPresent && + Option.isSome(existingBinding) + ) { + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + return true; + } + + if ( + Option.isSome(existingThread) && + hasImportBlockingActivity(existingThread.value, importedHistoryPresent) + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + if ( + Option.isSome(existingBinding) && + (existingBinding.value.provider !== provider || + existingBinding.value.providerInstanceId !== thread.providerInstanceId || + existingBinding.value.status !== "stopped") + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + // Install the cursor before the thread becomes visible. A concurrent + // real session can replace it, while insert-ignore keeps this import + // from replacing that newer binding. + if (Option.isNone(existingBinding)) { + yield* directory.upsert( + { + threadId, + provider, + providerInstanceId: thread.providerInstanceId, + status: "stopped", + runtimeMode: DEFAULT_RUNTIME_MODE, + resumeCursor: + thread.source === "codex" + ? { threadId: thread.providerSessionId } + : { threadId, resume: thread.providerSessionId }, + runtimePayload: { cwd: workspaceRoot }, + }, + { onConflict: "ignore" }, + ); + } + + if (Option.isNone(existingThread)) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: input.projectId, + title: thread.title, + modelSelection: { instanceId: thread.providerInstanceId, model }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: thread.createdAt, + historyImport: true, + }); + } + + if (!importedHistoryPresent) { + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + messages: thread.messages.map((message, index) => ({ + messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`), + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + }); + } + + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + + return true; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not import an agent session", { + provider: thread.source, + sessionId: thread.providerSessionId, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (imported) { + importedThreadIds.add(threadId); + importedCount += 1; + } else { + skippedCount += 1; + } + }), + ); + + return { importedCount, skippedCount } satisfies AgentSessionImportResult; +}); diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts new file mode 100644 index 000000000000..dc6d72a0ce63 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -0,0 +1,3088 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { describe, expect, it } from "@effect/vitest"; +import { + type OrchestrationProjectShell, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({ + id: ProjectId.make("project-1"), + title: "Imported", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +/** Only `getShellSnapshot` is exercised; the rest must not be called. */ +const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)), + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.succeed([]), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); + +/** + * Run a scan against the given homes. Homes are temp dirs created inside the + * test, so the layer is built per run rather than shared. + */ +interface ScannerTestInput { + readonly claudeHomePath: string; + readonly codexHomePath: string; + readonly importedWorkspaceRoots?: ReadonlyArray; + /** Base dir for the test ServerConfig; worktreesDir derives from it. */ + readonly configBaseDir?: string; + readonly providerInstances?: ContractServerSettings["providerInstances"]; +} + +const makeScannerTestLayer = (input: ScannerTestInput) => + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providers: { + claudeAgent: { homePath: input.claudeHomePath }, + codex: { homePath: input.codexHomePath }, + }, + ...(input.providerInstances === undefined + ? {} + : { providerInstances: input.providerInstances }), + }), + ServerConfig.layerTest( + input.claudeHomePath, + input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, + ), + makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ), + ), + ); + +const runScan = (input: ScannerTestInput) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.scan; + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(input.workspaceRoot).pipe( + Stream.runCollect, + Effect.map((outcomes) => Array.from(outcomes)), + ); + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + runRecentThreadOutcomes(input).pipe( + Effect.map((outcomes) => + outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])), + ), + ); + +const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { + readonly filePath: string; + readonly contents: string; + /** Epoch millis, so ordering assertions never depend on write timing. */ + readonly mtimeMs: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFileString(input.filePath, input.contents); + // Numeric utimes arguments are seconds, not milliseconds. + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); +}); + +/** Claude session line: the first record carries the real `cwd`. */ +const claudeSessionLine = (cwd: string) => + `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; + +/** Codex rollout line: session metadata is nested under `payload`. */ +const codexRolloutLine = (cwd: string) => + `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; + +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { + const records = + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "record-limit-session", cwd }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "First prompt" }, + }), + ].join("\n") + + "\n" + + "{}\n".repeat(99_998); + return overflow + ? records + + "\n" + + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Overflow prompt" }, + }) + + "\n" + : records; +} + +it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + describe("scan", () => { + it.effect("reads Claude project cwds from transcripts, newest first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); + const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); + + // Slugs are intentionally lossy; the scanner must not decode them. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), + contents: claudeSessionLine(newerWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: newerWorkspace, + title: path.basename(newerWorkspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-03-01T00:00:00.000Z", + alreadyImported: false, + }, + { + path: olderWorkspace, + title: path.basename(olderWorkspace), + sources: ["claudeAgent"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("groups Codex rollouts by cwd across date directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const rollout = (year: string, month: string, day: string, name: string) => + path.join(codexHomePath, "sessions", year, month, day, name); + + yield* writeTranscript({ + filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), + contents: codexRolloutLine(otherWorkspace), + mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-02-09T11:00:00.000Z", + alreadyImported: false, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["codex"], + threadCount: 2, + lastActiveAt: "2026-02-09T10:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "does not open a non-file %s transcript", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const transcriptPath = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") + : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); + yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(transcriptOpenCount).toBe(0); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "stops %s directory reads at the discovery operation budget", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const discoveryRoot = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects") + : path.join(codexHomePath, "sessions"); + const emptyDirectories = Array.from( + { length: 20_001 }, + (_, index) => `empty-${index.toString().padStart(5, "0")}`, + ); + let directoryReadCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed(emptyDirectories); + } + if (path.dirname(directory) === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(directoryReadCount).toBe(20_000); + }), + ); + + it.effect("merges the same cwd seen by both agents and flags imported projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "04", + "01", + "rollout-2026-04-01T09-00-00-aaa.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-04-01T09:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("returns the imported project ID through a realpath alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("matches a persisted project alias to a transcript realpath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspaceAlias], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspaceAlias, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("merges case aliases and preserves the persisted project path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("keeps case variants distinct when the filesystem identities differ", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const backingUpper = yield* makeTempDir("t3code-backing-upper-"); + const backingLower = yield* makeTempDir("t3code-backing-lower-"); + const aliasParent = yield* makeTempDir("t3code-case-aliases-"); + const upperWorkspace = path.join(aliasParent, "Repo"); + const lowerWorkspace = path.join(aliasParent, "repo"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"), + contents: claudeSessionLine(upperWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"), + contents: claudeSessionLine(lowerWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + fileSystem.stat( + filePath === upperWorkspace + ? backingUpper + : filePath === lowerWorkspace + ? backingLower + : filePath, + ), + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + upperWorkspace, + lowerWorkspace, + ]); + }), + ); + + it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-"); + const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-"); + const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"), + contents: claudeSessionLine(legacyWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexInstanceHome, + "sessions", + "2026", + "03", + "01", + "rollout-instance.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeInstanceHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexInstanceHome }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("scans each distinct home across multiple instances once", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const otherCodexHome = yield* makeTempDir("t3code-codex-other-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + for (const [home, cwd] of [ + [codexHomePath, workspace], + [otherCodexHome, otherWorkspace], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHomePath }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: otherCodexHome }, + }, + }, + }); + + expect(result.candidates).toHaveLength(2); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]); + expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual( + [workspace, otherWorkspace].sort(), + ); + }), + ); + + it.effect("honors provider instance home directory environment variables", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); + const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexEnvironmentHome, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], + config: {}, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("ignores invalid provider instances while scanning the remaining providers", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: 123 }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("does not scan provider instances disabled by the envelope or config", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-"); + const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-"); + const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-"); + const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-"); + + for (const [home, workspace, session] of [ + [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"], + [configDisabledHome, configWorkspace, "config-disabled"], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-envelope-disabled")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { homePath: envelopeDisabledHome }, + }, + [ProviderInstanceId.make("codex-config-disabled")]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: false, homePath: configDisabledHome }, + }, + }, + }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("ignores relative working directories from malformed transcripts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), + contents: claudeSessionLine(path.relative(path.resolve(), workspace)), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("drops candidates whose directory no longer exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes the home directory, temporary root, and T3 data directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + for (const [index, cwd] of [ + NodeOS.homedir(), + NodeOS.tmpdir(), + configBaseDir, + workspace, + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("excludes T3-managed worktree sandboxes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const fileSystem = yield* FileSystem.FileSystem; + + // worktreesDir derives as `/worktrees`, and the temp base + // dir contains no `.t3` segment — only the config-based prefix match + // can exclude this one. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; + + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("finds the cwd on a later line when the first records carry none", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + // Claude transcripts often open with records that have no cwd. + const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("reads a complete transcript record at the exact chunk boundary", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const record = claudeSessionLine(workspace).split("\n")[0]!; + const prefix = '{"padding":"'; + const suffix = `",${record.slice(1)}`; + const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(contents).toHaveLength(32 * 1024); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("finds session metadata after a first record larger than one chunk", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), + contents: `${history}${claudeSessionLine(workspace)}`, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); + const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); + const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); + const directories = [ + path.join(claudeHomePath, "projects", "p"), + path.join(secondHome, "projects", "p"), + ]; + const templates = directories.map((directory) => path.join(directory, "template.jsonl")); + for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { + const record = encodeTranscriptRecord({ cwd: workspace }); + yield* writeTranscript({ + filePath: templates[index]!, + contents: + " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, + }); + } + const resolveFile = (filePath: string) => { + const index = directories.indexOf(path.dirname(filePath)); + return index === -1 ? filePath : templates[index]!; + }; + let reservedBytes = 0; + let opens = 0; + const requests: number[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + const index = directories.indexOf(directory); + return index === -1 + ? fileSystem.readDirectory(directory, options) + : Effect.succeed( + Array.from( + { length: index === 0 ? 32 : count - 32 }, + (_, item) => `session-${item}.jsonl`, + ), + ); + }, + stat: (filePath) => fileSystem.stat(resolveFile(filePath)), + open: (filePath, options) => { + if (!directories.includes(path.dirname(filePath))) + return fileSystem.open(filePath, options); + opens += 1; + return fileSystem.open(resolveFile(filePath), options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => { + reservedBytes += Number(size); + requests.push(Number(size)); + return file.readAlloc(size); + }, + })), + ); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + firstWorkspace, + secondWorkspace, + ]); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); + expect(result.truncated).toBe(count === 65 ? true : undefined); + expect(opens).toBe(64); + expect(reservedBytes).toBe(64 * 1024 * 1024); + expect(requests[0]).toBe(8 * 1024); + expect(Math.max(...requests)).toBe(8 * 1024); + }), + ); + + it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); + const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-short-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + const record = encodeTranscriptRecord({ cwd: workspace }); + const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; + const bytes = new TextEncoder().encode(contents); + yield* writeTranscript({ + filePath: template, + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let operations = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed( + Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), + ) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + operations += 1; + let offset = 0; + return fileSystem.open(template, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: () => + Effect.sync(() => { + operations += 1; + if (offset === bytes.length) return Option.none(); + return Option.some(bytes.subarray(offset, ++offset)); + }), + })), + ); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(operations).toBe(20_000); + expect(result.candidates[0]?.threadCount).toBe(50); + expect(result.truncated).toBe(count === 51 ? true : undefined); + }), + ); + + it.effect("bounds malformed metadata records without excluding another account", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-record-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + yield* writeTranscript({ + filePath: template, + contents: "x\n".repeat(1_001), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(secondHome, "projects", "p", "session.jsonl"), + contents: encodeTranscriptRecord({ cwd: workspace }), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let malformedOpens = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + malformedOpens += 1; + return fileSystem.open(template, options); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + expect(malformedOpens).toBe(100); + expect(result.truncated).toBe(true); + }), + ); + + it.effect.each([19_999, 20_000])( + "reports unfinished directory work for %s project directories", + (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); + const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); + const projectsDir = path.join(claudeHomePath, "projects"); + let reads = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === projectsDir) { + reads += 1; + return Effect.succeed( + Array.from({ length: count }, (_, index) => `project-${index}`), + ); + } + if (path.dirname(directory) === projectsDir) { + reads += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(reads).toBe(20_000); + expect(result.candidates).toEqual([]); + expect(result.truncated).toBe(count === 20_000 ? true : undefined); + }), + ); + + it.effect("skips malformed transcripts without failing the scan", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + // Valid JSON, but no cwd anywhere in the record. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("returns an empty result when neither home directory exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-missing-homes-"); + + const result = yield* runScan({ + claudeHomePath: path.join(root, "no-claude"), + codexHomePath: path.join(root, "no-codex"), + }); + + expect(result.candidates).toEqual([]); + expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }), + ); + }); + + describe("recentThreads", () => { + it.effect.each([false, true])( + "counts terminal newlines correctly with record overflow=%s", + (overflow) => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); + const workspace = yield* makeTempDir("t3code-record-limit-project-"); + const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-records.jsonl"), + contents: makeRecordLimitTranscript(workspace, overflow), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-older.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "older-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Older prompt" }, + }), + ].join("\n"), + mtimeMs: nowMs - 1_000, + }); + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual( + overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], + ); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? outcome.thread.messages.map((message) => message.text) + : [], + ), + ).toEqual([overflow ? "Older prompt" : "First prompt"]); + }), + ); + + it.effect("imports recent Claude and Codex sessions for the selected project only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const claudeTranscript = (cwd: string, sessionId: string) => + `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + timestamp: "2026-08-23T12:01:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), + contents: claudeTranscript(workspace, "claude-recent"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), + contents: claudeTranscript(workspace, "claude-old"), + mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), + contents: claudeTranscript(otherWorkspace, "claude-other"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-codex-recent.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "codex-recent", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { type: "user_message", message: "Review this code" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Looks good" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "codex-recent", + "claude-recent", + ]); + expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Review this code", "Looks good"], + ["Fix the project", "Done"], + ]); + }), + ); + + it.effect("imports history recorded with a case alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "user", + cwd: workspaceAlias, + sessionId: "case-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: "Import case alias history" }, + }), + encodeTranscriptRecord({ + type: "assistant", + sessionId: "case-session", + timestamp: "2026-08-24T10:01:00.000Z", + message: { role: "assistant", content: "Imported" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); + }), + ); + + it.effect("keeps the provider instance that owns a custom session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const customHome = yield* makeTempDir("t3code-codex-custom-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "custom-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use my work account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: customHome }, + }, + }, + }); + + expect(threads[0]?.providerInstanceId).toBe("codex-work"); + }), + ); + + it.effect("suppresses duplicate session copies without reporting a skipped import", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "copied-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session once" }, + }), + ].join("\n"); + + for (const [name, mtimeMs] of [ + ["rollout-copy-a.jsonl", nowMs], + ["rollout-copy-b.jsonl", nowMs - 1], + ] as const) { + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name), + contents, + mtimeMs, + }); + } + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "copied-session" }, + }); + }), + ); + + it.effect("shares a 64 MiB full-read budget across providers without hiding projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); + const workspace = yield* makeTempDir("t3code-budget-workspace-"); + const transcriptPaths = new Set(); + for (const [index, source] of [ + "codex", + "claudeAgent", + "codex", + "claudeAgent", + "codex", + ].entries()) { + const sessionId = `budget-session-${index}`; + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ) + : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); + const contents = + source === "codex" + ? [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + : encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + message: { content: "Imported prompt" }, + }); + transcriptPaths.add(filePath); + yield* writeTranscript({ + filePath, + contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), + mtimeMs: nowMs - index * 1_000, + }); + } + + const opens = new Map(); + let fullReadBytes = 0; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + const count = (opens.get(filePath) ?? 0) + 1; + opens.set(filePath, count); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => + !transcriptPaths.has(filePath) || count === 1 + ? file + : { + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.sync(() => { + if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; + }), + ), + ), + }, + ), + ); + }, + }); + const outcomes = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates[0]?.threadCount).toBe(5); + return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual([ + "Importable", + "Importable", + "Importable", + "Importable", + "Skipped", + ]); + expect(fullReadBytes).toBe(64 * 1024 * 1024); + }), + ); + + it.effect("skips excessive records without blocking an older valid transcript", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); + const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); + for (const [sessionId, padding, mtimeMs] of [ + ["excessive", "\n".repeat(100_001), nowMs], + ["older", "", nowMs - 1_000], + ] as const) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ), + contents: + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + padding, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + }), + ); + + for (const source of ["claudeAgent", "codex"] as const) { + for (const replacement of [ + "same root", + "other root", + "symlink alias", + "other then same", + ] as const) { + it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)( + `rechecks ${source} snapshot cwd after replacement with ${replacement}`, + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixture = yield* makeTempDir("t3code-replaced-cwd-"); + const workspace = path.join(fixture, "original"); + const otherWorkspace = path.join(fixture, "other"); + const alias = path.join(fixture, "alias"); + const claudeHomePath = path.join(fixture, "claude"); + const codexHomePath = path.join(fixture, "codex"); + yield* fileSystem.makeDirectory(workspace); + yield* fileSystem.makeDirectory(otherWorkspace); + if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias); + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ) + : path.join(claudeHomePath, "projects", "p", "replaced.jsonl"); + const makeContents = (cwd: string, text: string, laterCwd?: string) => + [ + ...(source === "codex" + ? [ + { type: "session_meta", payload: { id: "replacement-session", cwd } }, + { type: "event_msg", payload: { type: "user_message", message: text } }, + ] + : [ + { + type: "user", + cwd, + sessionId: "replacement-session", + message: { content: text }, + }, + ]), + ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]), + ] + .map((record) => encodeTranscriptRecord(record)) + .join("\n"); + yield* writeTranscript({ + filePath, + contents: makeContents(workspace, "Original prompt"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + const replacementCwd = + replacement === "symlink alias" + ? alias + : replacement === "same root" + ? workspace + : otherWorkspace; + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: makeContents( + replacementCwd, + "Replacement prompt", + replacement === "other then same" ? workspace : undefined, + ), + mtimeMs: nowMs, + }); + const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + if (replacement === "same root" || replacement === "symlink alias") { + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { messages: [{ text: "Replacement prompt" }] }, + }); + } else { + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + } + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + } + } + + it.effect("checks file identity and provider before skipping completed history", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); + const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); + const workspace = yield* makeTempDir("t3code-completed-workspace-"); + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ); + const contents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath, + contents: contents("original-session"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + const imported = initial[0]; + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; + const completed = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + const wrongProvider = yield* scanner + .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) + .pipe(Stream.runCollect); + expect(wrongProvider[0]?._tag).toBe("Importable"); + + // Keep the old inode allocated while replacing the path with an equal-size file. + yield* fileSystem.open(filePath); + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: contents("replaced-session"), + mtimeMs: nowMs, + }); + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, + }); + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + + it.effect("reports an eligible transcript over 16 MiB as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcript = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "large-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this large session" }, + }), + ] + .join("\n") + .padEnd(16 * 1024 * 1024 + 1, " "); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), + contents: transcript, + mtimeMs: nowMs, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("reports stat, read, and parse failures as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const missingPath = path.join(codexHomePath, "missing.jsonl"); + const transcriptPaths = { + stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), + read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), + parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), + }; + const transcriptContents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session" }, + }), + ].join("\n"); + + yield* writeTranscript({ + filePath: transcriptPaths.stat, + contents: transcriptContents("stat-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.read, + contents: transcriptContents("read-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.parse, + contents: encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parse-session", cwd: workspace }, + }), + mtimeMs: nowMs, + }); + + let statCount = 0; + let readOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); + statCount += 1; + return fileSystem.stat(statCount === 1 ? filePath : missingPath); + }, + open: (filePath, options) => { + if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); + readOpenCount += 1; + return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); + }), + ); + + it.effect("does not reopen a transcript that becomes a non-file after discovery", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const nonFilePath = yield* makeTempDir("t3code-non-file-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-changed.jsonl", + ); + yield* writeTranscript({ + filePath: transcriptPath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "changed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + let transcriptStatCount = 0; + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPath) return fileSystem.stat(filePath); + transcriptStatCount += 1; + return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); + }, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptStatCount).toBe(2); + expect(transcriptOpenCount).toBe(1); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not import a transcript dated after the current time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-future.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "future-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Future work" }, + }), + ].join("\n"), + mtimeMs: nowMs + 1, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([]); + }), + ); + + it.effect("skips growth during reading without exceeding the reserved bytes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("skips a transcript that shrinks after its size check", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-shrinking.jsonl", + ); + const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shrinking-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: `${contents}\n${"padding".repeat(100)}`, + mtimeMs: nowMs, + }); + yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + return fileSystem.open( + transcriptOpenCount === 1 ? transcriptPath : shrunkPath, + options, + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not read the second transcript when the consumer takes one thread", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const makeCodexTranscript = (sessionId: string, text: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: text }, + }), + ].join("\n"); + const olderPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "23", + "rollout-older.jsonl", + ); + const newerPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-newer.jsonl", + ); + yield* writeTranscript({ + filePath: olderPath, + contents: makeCodexTranscript("older-session", "Older prompt"), + mtimeMs: nowMs - 1_000, + }); + yield* writeTranscript({ + filePath: newerPath, + contents: makeCodexTranscript("newer-session", "Newer prompt"), + mtimeMs: nowMs, + }); + + const openCounts = new Map(); + const contentReads: Array = []; + const trackedPaths = new Set([olderPath, newerPath]); + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (trackedPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + if (count === 2) contentReads.push(filePath); + } + return fileSystem.open(filePath, options); + }, + }); + + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(workspace).pipe( + Stream.take(1), + Stream.runCollect, + Effect.map((items) => Array.from(items)), + ); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["newer-session"]); + expect(contentReads).toEqual([newerPath]); + expect(openCounts.get(olderPath)).toBe(1); + }), + ); + + it.effect("does not import sessions from a T3-managed worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); + yield* fileSystem.makeDirectory(workspace, { recursive: true }); + + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-managed.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "managed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: workspace, + }); + + expect(threads).toEqual([]); + }), + ); + + it.effect("uses one deterministic provider instance for a shared session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the shared session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + }), + ); + + it.effect("uses configured order when custom instances share a session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the first account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); + }), + ); + + it.effect("keeps a second account when the first has 5000 newer files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); + const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); + const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); + const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); + const oldTranscript = path.join(oldDirectory, "old.jsonl"); + const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); + + yield* writeTranscript({ + filePath: oldTranscript, + contents: encodeTranscriptRecord({ + type: "user", + cwd: oldWorkspace, + sessionId: "old-session", + message: { role: "user", content: "Old work" }, + }), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(recentDirectory, "recent.jsonl"), + contents: encodeTranscriptRecord({ + type: "user", + cwd: recentWorkspace, + sessionId: "recent-session", + message: { role: "user", content: "Recent work" }, + }), + mtimeMs: nowMs - 1_000, + }); + + const simulatedOldTranscripts = Array.from( + { length: 5_000 }, + (_, index) => `old-${index}.jsonl`, + ); + const resolveTranscript = (filePath: string) => + path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") + ? oldTranscript + : filePath; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => + directory === oldDirectory + ? Effect.succeed(simulatedOldTranscripts) + : fileSystem.readDirectory(directory, options), + stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), + open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), + }); + + const input = { + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: recentHome }, + }, + }, + }; + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.truncated).toBe(true); + return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer(input)), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["recent-session"]); + }), + ); + }); +}); + +describe("parseAgentSessionTranscript", () => { + it.each([false, true])( + "handles the exact record limit and an interior blank overflow=%s", + (overflow) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: makeRecordLimitTranscript("/project", overflow), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + if (overflow) expect(thread).toBeNull(); + else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]); + }, + ); + + it("keeps Claude text and titles while dropping malformed and tool records", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + "not valid json", + JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isMeta: true, + message: { role: "user", content: "Injected skill instructions" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isCompactSummary: true, + message: { role: "user", content: "Injected compaction summary" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "Updated the login flow" }], + }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "", + content: [{ type: "text", text: "The provider request failed" }], + }, + }), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toMatchObject({ + providerSessionId: "claude-session", + title: "Fix authentication", + model: "claude-sonnet-5", + messages: [ + { role: "user", text: "Fix authentication" }, + { role: "assistant", text: "Updated the login flow" }, + { role: "assistant", text: "The provider request failed" }, + ], + }); + }); + + it("drops injected Codex instructions while keeping the visible user event", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\nInternal setup instructions\n", + }, + ], + }, + }), + JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: "Fix the actual bug" }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Fix the actual bug" }], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Fixed" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Fix the actual bug", + "Fixed", + ]); + }); + + it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...laterAssistantMessages, + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + }); + + it("restores the canonical first prompt when a later user message remains", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const assistantMessages = Array.from({ length: 198 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...assistantMessages, + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T11:58:30.000Z", + payload: { type: "user_message", message: "Keep this later prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T11:59:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Keep this latest response" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + expect( + thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()), + ).toHaveLength(1); + expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe( + true, + ); + expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); + }); + + it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, + content: [{ type: "input_text", text: "Keep this older prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Keep this newer prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Ask again when needed" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this older prompt", + "Keep this newer prompt", + "Ask again when needed", + "Keep this newer prompt", + ]); + }); + + it("preserves response user text when Codex turn metadata is ambiguous", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: ["unexpected"], + content: [{ type: "input_text", text: "Keep this legacy prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: " " }, + content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this legacy prompt", + "Keep this prompt with a blank turn ID", + ]); + }); + + it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "fork-session", forked_from_id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Continue in the fork" }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.providerSessionId).toBe("fork-session"); + }); + + it("skips Codex transcripts without a resumable session ID", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "This transcript has no session metadata" }, + }), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("uses the canonical Codex event when its turn has generated response context", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\n/tmp/project\nzsh\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "# AGENTS.md instructions for /tmp/project\n\n\nPrivate project rules\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: "Do something here so it looks like a real project.", + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "Do something here so it looks like a real project.", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Created the project." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("Do something here so it looks like a real project."); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Do something here so it looks like a real project.", + "Created the project.", + ]); + }); + + it("preserves context markup in response-only Codex messages", () => { + const context = "\n/tmp/project\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: context, + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Initialize Git and add a README." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([ + context, + "Initialize Git and add a README.", + ]); + }); + + it("preserves a canonical Codex event that starts with context markup", () => { + const prompt = + "\n/tmp/project\n\n\nCreate a useful project."; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("preserves a Codex request heading in a canonical event", () => { + const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("## My request for Codex:"); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("keeps context markup quoted inside visible Codex user text", () => { + const quoted = + "Do not remove this example:\n\n/tmp/example\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: quoted }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); + }); + + it("skips sessions without a visible user message", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: "Done" }, + }), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "claude-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("keeps the first prompt when later assistant output exceeds the message limit", () => { + const transcript = [ + encodeTranscriptRecord({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: "Keep this prompt" }, + }), + ...Array.from({ length: 250 }, (_, index) => + encodeTranscriptRecord({ + type: "assistant", + message: { role: "assistant", content: `Assistant update ${index}` }, + }), + ), + ].join("\n"); + + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: transcript, + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]?.text).toBe("Keep this prompt"); + expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); + }); +}); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts new file mode 100644 index 000000000000..bc6d093d42a1 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -0,0 +1,1313 @@ +/** + * AgentSessionScanner - discovery of projects a user already works on. + * + * Claude Code and Codex both keep a per-session transcript on disk, and each + * transcript records the directory the session ran in. Reading those `cwd` + * values gives us the set of directories worth offering as projects during + * onboarding, without asking the user to browse the filesystem. + * + * The scan is read-only and best-effort: an unreadable home, a malformed + * transcript, or a directory that has since been deleted is skipped rather + * than failing the scan. Project creation stays with the client, which + * dispatches `project.create` for whichever candidates the user picks. + * + * @module project/AgentSessionScanner + */ +import * as NodeOS from "node:os"; + +import { + AgentSessionScanError, + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + ProviderInstanceId, + resolveProviderInstanceEnabled, + type AgentSessionImportSource, + type AgentSessionProjectCandidate, + type AgentSessionScanResult, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +/** Chunk size for full transcript reads. */ +const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; +/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ +const METADATA_READ_BYTES = 8 * 1024; +/** Prevent malformed transcripts from turning project discovery into a full file scan. */ +const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; + +/** + * Upper bound on transcripts inspected (first line read) per source. + * Newest-first ordering means the cap drops only stale sessions when a home + * directory is unusually large. + */ +const MAX_TRANSCRIPTS_PER_SOURCE = 5000; + +/** + * Upper bound on discovery filesystem operations per source. Newest-first + * ordering needs mtimes before the read cap can be applied, so directory reads + * and candidate stats share a larger budget. Once it runs out the scan stops. + */ +const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; +const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; +const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; +const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_IMPORTED_TRANSCRIPT_BYTES = 16 * 1024 * 1024; +const MAX_IMPORTED_MESSAGES = 200; +const MAX_IMPORT_BYTES = 64 * 1024 * 1024; +const MAX_IMPORT_TRANSCRIPTS = 100; +const MAX_IMPORT_RECORDS = 100_000; + +const TranscriptContentBlock = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), +}); + +const TranscriptMessage = Schema.Struct({ + role: Schema.optional(Schema.String), + content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), + model: Schema.optional(Schema.String), +}); + +const CodexTurnMetadata = Schema.Struct({ + turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), +}); + +const TranscriptRecord = Schema.Struct({ + type: Schema.optional(Schema.String), + timestamp: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + aiTitle: Schema.optional(Schema.String), + isSidechain: Schema.optional(Schema.Boolean), + isMeta: Schema.optional(Schema.Boolean), + isCompactSummary: Schema.optional(Schema.Boolean), + message: Schema.optional(TranscriptMessage), + payload: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + session_id: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + role: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + content: Schema.optional(Schema.Array(TranscriptContentBlock)), + internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), + }), + ), +}); + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); +const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); + +export interface AgentSessionThreadMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +export interface AgentSessionThread { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly title: string; + readonly model: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly messages: ReadonlyArray; +} + +export type AgentSessionRecentThread = + | { + readonly _tag: "Importable"; + readonly thread: AgentSessionThread; + readonly source: AgentSessionImportSource; + } + | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Skipped" }; + +/** Service tag for agent session discovery. */ +export class AgentSessionScanner extends Context.Service< + AgentSessionScanner, + { + /** + * Discover every directory the configured Claude and Codex homes have run + * a session in. Candidates are returned newest-first; the client decides + * which ones to import and how far back to look. Fails with the contract + * error directly — there is no server-local context worth wrapping. + */ + readonly scan: Effect.Effect; + readonly recentThreads: ( + workspaceRoot: string, + completedSources?: ReadonlyArray, + ) => Stream.Stream; + } +>()("t3/project/AgentSessionScanner") {} + +type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; + +/** A single directory's worth of evidence from one source. */ +interface RawCandidate { + readonly cwd: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly threadCount: number; + readonly lastActiveAtMs: number | null; + readonly transcripts: ReadonlyArray<{ + readonly filePath: string; + readonly mtimeMs: number | null; + }>; +} + +interface TranscriptCandidate { + readonly filePath: string; + readonly mtimeMs: number; + readonly providerInstanceId: ProviderInstanceId; + readonly size: number; +} + +interface MetadataReadBudget { + bytesRemaining: number; + operationsRemaining: number; + recordsRemaining: number; + truncated: boolean; +} + +function selectMetadataTranscripts(transcripts: ReadonlyArray) { + const selected: Array = []; + let pending = Array.from( + Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), + (entries) => entries.values(), + ); + while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { + const nextRound: typeof pending = []; + for (const iterator of pending) { + if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; + const next = iterator.next(); + if (next.done) continue; + selected.push(next.value); + nextRound.push(iterator); + } + pending = nextRound; + } + return selected; +} + +function splitTranscriptRecords(contents: string, limit: number): string[] { + const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; + return records.split("\n", limit); +} + +function extractText( + content: string | ReadonlyArray | undefined, +): string { + if (typeof content === "string") return content.trim(); + if (content === undefined) return ""; + return content + .filter( + (block) => + block.type === "text" || block.type === "input_text" || block.type === "output_text", + ) + .map((block) => block.text?.trim() ?? "") + .filter((text) => text.length > 0) + .join("\n"); +} + +function normalizeTimestamp(value: string | undefined, fallback: string): string { + if (value === undefined) return fallback; + const parsed = DateTime.make(value); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; +} + +function codexTurnId(metadata: unknown): string | null { + const decoded = decodeCodexTurnMetadata(metadata); + if ( + Option.isNone(decoded) || + typeof decoded.value.turn_id !== "string" || + decoded.value.turn_id.trim().length === 0 + ) { + return null; + } + return decoded.value.turn_id; +} + +/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ +export function parseAgentSessionTranscript( + input: { + readonly contents: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly fallbackSessionId: string; + readonly lastActiveAtMs: number; + }, + lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), +): AgentSessionThread | null { + if (lines.length > MAX_IMPORT_RECORDS) return null; + const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); + // Claude filenames are session IDs. Codex rollout filenames include extra + // timestamp text, so only transcript metadata can provide a resumable ID. + let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; + let title: string | null = null; + let model: string | null = null; + let hasCodexSessionId = false; + const messages: Array = []; + let firstUserMessage: + | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) + | undefined; + function* decodedRecords() { + for (const line of lines) { + const decoded = decodeTranscriptRecord(line); + if (Option.isSome(decoded)) yield decoded.value; + } + } + + // A Codex response item can include generated setup text beside the real + // prompt. Suppress response-user records only when the shared turn ID and a + // verbatim event copy prove which prompt the user submitted. + const canonicalCodexResponseUserIndices = new Set(); + let canonicalUserTextsInTurn = new Set(); + let responseUsersInTurn: Array<{ + readonly index: number; + readonly turnId: string; + readonly text: string; + }> = []; + const finishCodexTurn = () => { + const canonicalTurnIds = new Set( + responseUsersInTurn.flatMap((responseUser) => + canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], + ), + ); + for (const responseUser of responseUsersInTurn) { + if (canonicalTurnIds.has(responseUser.turnId)) { + canonicalCodexResponseUserIndices.add(responseUser.index); + } + } + canonicalUserTextsInTurn = new Set(); + responseUsersInTurn = []; + }; + if (input.source === "codex") { + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "assistant" + ) { + finishCodexTurn(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message?.trim() ?? ""; + if (text.length > 0) canonicalUserTextsInTurn.add(text); + continue; + } + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "user" + ) { + const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); + const text = extractText(record.payload.content); + if (turnId !== null && text.length > 0) { + responseUsersInTurn.push({ index: recordIndex, turnId, text }); + } + } + } + finishCodexTurn(); + } + + const retainMessage = ( + message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, + ) => { + if (firstUserMessage === undefined && message.role === "user") { + firstUserMessage = message; + } + messages.push(message); + if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); + }; + + const hasMatchingCodexEventInTurn = (text: string) => { + const comparisonText = text.trim(); + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") return false; + if ( + message?.role === "user" && + !message.codexResponseUser && + message.text.trim() === comparisonText + ) { + return true; + } + } + return false; + }; + + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if (input.source === "claudeAgent") { + if ( + record.isSidechain === true || + record.isMeta === true || + record.isCompactSummary === true + ) { + continue; + } + if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); + if (record.aiTitle?.trim()) title = record.aiTitle.trim(); + const messageModel = record.message?.model?.trim(); + // Claude uses this sentinel for local error responses. It is not a + // model ID that can be selected when the imported session resumes. + if (messageModel && messageModel !== "") model = messageModel; + if (record.type !== "user" && record.type !== "assistant") { + continue; + } + + const text = extractText(record.message?.content); + if (text.length === 0) continue; + retainMessage({ + role: record.type, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + + if (record.type === "session_meta") { + const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); + if (!hasCodexSessionId && sessionId) { + providerSessionId = sessionId; + hasCodexSessionId = true; + } + continue; + } + if (record.type === "turn_context" && record.payload?.model?.trim()) { + model = record.payload.model.trim(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message ?? ""; + if (text.trim().length === 0) continue; + // Codex can write the same prompt as both a response item and an event. + // Remove only the matching response copy so mixed-format logs keep every + // distinct user message. + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") break; + if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { + if (firstUserMessage === message) firstUserMessage = undefined; + messages.splice(index, 1); + break; + } + } + retainMessage({ + role: "user", + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + if ( + record.type !== "response_item" || + record.payload?.type !== "message" || + (record.payload.role !== "user" && record.payload.role !== "assistant") + ) { + continue; + } + + const extractedText = extractText(record.payload.content); + if (extractedText.length === 0) continue; + if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { + continue; + } + if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { + continue; + } + retainMessage({ + role: record.payload.role, + text: extractedText, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: record.payload.role === "user", + }); + } + + const visibleMessages = messages.map( + ({ codexResponseUser: _codexResponseUser, ...message }) => message, + ); + if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; + const firstUserMessageRetained = messages.includes(firstUserMessage); + const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; + const retainedMessages = firstUserMessageRetained + ? visibleMessages + : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; + const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); + + return { + source: input.source, + providerInstanceId: input.providerInstanceId, + providerSessionId, + title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"), + model, + createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, + updatedAt: fallbackTimestamp, + messages: retainedMessages, + }; +} + +/** + * T3 Code runs its own agent sessions inside disposable worktrees. Their + * transcripts look exactly like user sessions, but re-importing the app's own + * sandboxes as projects is never right. Matches this server's configured + * worktrees directory plus the conventional `.t3/worktrees` layout, which + * also catches sandboxes from other T3 homes on the same machine. Separators + * are normalized (and, on Windows, case folded) so the prefix match holds + * there too. Callers check both the recorded spelling and its realpath so a + * symlink into the worktrees directory cannot bypass the filter. + */ +function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { + const normalized = `${value.replaceAll("\\", "/")}/`; + return caseFold ? normalized.toLowerCase() : normalized; +} + +function isT3ManagedWorktree( + candidatePath: string, + worktreesDir: string, + caseFold: boolean, +): boolean { + const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); + return ( + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || + normalized.includes("/.t3/worktrees/") + ); +} + +/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ +function extractCwd(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { + return record.cwd; + } + // Codex nests session metadata under `payload`. + const payload = record.payload; + if (typeof payload === "object" && payload !== null) { + const nested = (payload as Record).cwd; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested; + } + } + return null; +} + +function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { + return { + filePath, + size: Number(stats.size), + mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), + device: stats.dev, + inode: Option.getOrNull(stats.ino), + birthtimeMs: Option.match(stats.birthtime, { + onNone: () => null, + onSome: (date) => date.getTime(), + }), + }; +} + +function sameTranscriptIdentity( + left: ReturnType, + right: ReturnType, +): boolean { + return ( + left.filePath === right.filePath && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.device === right.device && + left.inode === right.inode && + left.birthtimeMs === right.birthtimeMs + ); +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const baseDir = path.resolve(serverConfig.baseDir); + const worktreesDir = path.resolve(serverConfig.worktreesDir); + // Windows filesystems are case-insensitive, so path prefix checks there + // must case fold. + const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; + const hostEnvironment = yield* HostProcessEnvironment; + const excludedProjectRoots = new Set( + [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) => + normalizeProjectPathForComparison(path.resolve(directory)), + ), + ); + + const isExcludedProjectPath = (candidatePath: string) => + excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(baseDir, foldWorktreeCase), + ) || + isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const statOption = (target: string) => + fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + + /** Match directory aliases without assuming the host volume is case-insensitive. */ + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( + target: string, + knownStats?: FileSystem.File.Info, + ) { + const resolved = path.resolve(target); + const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); + if ( + Option.isSome(stats) && + Option.isSome(stats.value.ino) && + Number.isSafeInteger(stats.value.ino.value) && + stats.value.ino.value > 0 + ) { + return `inode:${stats.value.dev}:${stats.value.ino.value}`; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + return `path:${normalizeProjectPathForComparison(realPath)}`; + }); + + // A large history snapshot can precede session metadata. Read bounded + // chunks until a complete record names its cwd or the safety budget ends. + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( + transcript: TranscriptCandidate, + budget: MetadataReadBudget, + ) { + if (transcript.size === 0) return null; + if ( + budget.bytesRemaining === 0 || + budget.operationsRemaining < 2 || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return null; + } + budget.operationsRemaining -= 1; + return yield* Effect.scoped( + fileSystem.open(transcript.filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let remaining = ""; + let bytesRead = 0; + let recordsRead = 0; + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const reserveRecord = () => { + if ( + recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return false; + } + recordsRead += 1; + budget.recordsRemaining -= 1; + return true; + }; + const readLastRecord = () => { + const record = remaining + decoder.decode(); + return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); + }; + + while (bytesRead < maxBytes) { + if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { + budget.truncated = true; + return null; + } + const readSize = Math.min( + METADATA_READ_BYTES, + maxBytes - bytesRead, + budget.bytesRemaining, + ); + budget.operationsRemaining -= 1; + budget.bytesRemaining -= readSize; + const next = yield* file.readAlloc(readSize); + if (Option.isNone(next)) { + return readLastRecord(); + } + + bytesRead += next.value.byteLength; + remaining += decoder.decode(next.value, { stream: true }); + const lines = remaining.split("\n"); + remaining = lines.pop() ?? ""; + + for (const line of lines) { + if (!reserveRecord()) return null; + const cwd = extractCwd(line.trim()); + if (cwd !== null) return cwd; + } + } + + if (bytesRead < transcript.size) { + budget.truncated = true; + return null; + } + return readLastRecord(); + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** Check the open file before and after reading, without reading past its reserved byte budget. */ + const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( + filePath: string, + expected: ReturnType, + ) { + if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + + return yield* Effect.scoped( + fileSystem.open(filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { + return null; + } + const decoder = new TextDecoder(); + let contents = ""; + let bytesRead = 0; + + while (bytesRead < expected.size) { + const next = yield* file.readAlloc( + Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), + ); + if (Option.isNone(next)) { + return null; + } + + bytesRead += next.value.byteLength; + contents += decoder.decode(next.value, { stream: true }); + } + + return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) + ? contents + decoder.decode() + : null; + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * environment, then `~/.claude`. + */ + const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { + const configured = homePath.trim(); + if (configured.length > 0) { + return path.resolve(expandHomePath(configured)); + } + const fromEnvironment = environmentHome?.trim() ?? ""; + if (fromEnvironment.length > 0) { + return path.resolve(expandHomePath(fromEnvironment)); + } + return path.join(NodeOS.homedir(), ".claude"); + }; + + const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const projectsDir = path.join(homePath, "projects"); + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const projectDirectories = yield* readDirectory(projectsDir); + const transcripts: Array = []; + + for (const projectDirectory of projectDirectories) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(projectsDir, projectDirectory); + const directoryTranscripts = (yield* readDirectory(directory)) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry)); + + for (const filePath of directoryTranscripts) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isNone(stats) || + stats.value.type !== "File" || + Option.isNone(stats.value.mtime) + ) { + continue; + } + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + return { transcripts, truncated }; + }, + ); + + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const sessionsDir = path.join(homePath, "sessions"); + + const transcripts: Array = []; + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + // Date-partitioned directories sort chronologically, so walking them in + // reverse spends each home's share of the operation budget on recent sessions. + for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const month of (yield* readDirectory(path.join(sessionsDir, year))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(sessionsDir, year, month, day); + for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { + if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const filePath = path.join(directory, entry); + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + } + } + return { transcripts, truncated }; + }, + ); + + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( + source: AgentSessionSource, + transcripts: ReadonlyArray, + budget: MetadataReadBudget, + ) { + const byOwnerAndCwd = new Map< + string, + { + cwd: string; + providerInstanceId: ProviderInstanceId; + lastActiveAtMs: number; + transcripts: Array<{ filePath: string; mtimeMs: number }>; + } + >(); + + for (const transcript of transcripts) { + const cwd = yield* readCwd(transcript, budget); + if (cwd === null) continue; + const key = `${transcript.providerInstanceId}\0${cwd}`; + const existing = byOwnerAndCwd.get(key); + if (existing) { + existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); + existing.transcripts.push(transcript); + } else { + byOwnerAndCwd.set(key, { + cwd, + providerInstanceId: transcript.providerInstanceId, + lastActiveAtMs: transcript.mtimeMs, + transcripts: [transcript], + }); + } + } + + return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({ + cwd: group.cwd, + source, + providerInstanceId: group.providerInstanceId, + threadCount: group.transcripts.length, + lastActiveAtMs: group.lastActiveAtMs, + transcripts: group.transcripts, + })); + }); + + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); + + const raw: Array = []; + let truncated = false; + + for (const source of ["claudeAgent", "codex"] as const) { + const instances: Array<{ + readonly instanceId: ProviderInstanceId; + readonly config: ProviderInstanceConfig; + }> = Object.entries(settings.providerInstances) + .filter( + ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), + ) + .map(([instanceId, config]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + config, + })); + if (!Object.hasOwn(settings.providerInstances, source)) { + const legacyInstance = { + instanceId: ProviderInstanceId.make(source), + config: { + driver: ProviderDriverKind.make(source), + config: settings.providers[source], + }, + }; + if (resolveProviderInstanceEnabled(legacyInstance.config)) { + instances.push(legacyInstance); + } + } + + // A shared home contains one copy of each session. Prefer the built-in + // instance as its owner, then keep configured order for custom accounts. + instances.sort((left, right) => { + const leftDefault = left.instanceId === source ? 0 : 1; + const rightDefault = right.instanceId === source ? 0 : 1; + return leftDefault - rightDefault; + }); + const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; + const seenHomes = new Set(); + for (const { instanceId, config: instance } of instances) { + const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const environmentHome = + instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? + hostEnvironment[homeVariable]; + + let homePath: string; + if (source === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + } else { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const codexSettings = + config.value.homePath.trim().length === 0 && + config.value.shadowHomePath.trim().length === 0 && + environmentHome?.trim() + ? { ...config.value, homePath: environmentHome } + : config.value; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + homePath = layout.sharedHomePath; + } + + const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; + if (seenHomes.has(homeKey)) continue; + seenHomes.add(homeKey); + homes.push({ homePath, providerInstanceId: instanceId }); + } + + const transcriptCandidates: Array = []; + const baseOperationBudget = Math.floor( + MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), + ); + const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); + for (const [index, home] of homes.entries()) { + const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); + if (operationBudget === 0) { + truncated = true; + continue; + } + const discovered = yield* source === "claudeAgent" + ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) + : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + truncated ||= discovered.truncated; + transcriptCandidates.push(...discovered.transcripts); + } + + transcriptCandidates.sort( + (left, right) => + right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), + ); + if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { + truncated = true; + } + // Give each account a turn before taking another file from the same home. + const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); + const metadataBudget: MetadataReadBudget = { + bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, + operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, + recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, + truncated: false, + }; + raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); + truncated ||= metadataBudget.truncated; + } + + return { candidates: raw, truncated }; + }); + + let cachedCandidates: ReadonlyArray | null = null; + + const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + const { candidates: raw, truncated } = yield* collectCandidates(); + cachedCandidates = raw; + + // Filesystem identity merges symlinks and case aliases without collapsing + // distinct case-sensitive directories. + const merged = new Map< + string, + { + path: string; + sources: Array; + threadCount: number; + lastActiveAtMs: number | null; + } + >(); + const directoryKeys = new Map(); + + for (const candidate of raw) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if (isExcludedProjectPath(resolved)) continue; + let key = directoryKeys.get(resolved); + if (key === undefined) { + const stats = yield* statOption(resolved); + // Directories that no longer exist can't be imported. + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directoryKeys.set(resolved, ""); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // A symlink can point into the worktrees directory even when its own + // spelling doesn't; check again with links resolved. + if (isExcludedProjectPath(realPath)) { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + } + directoryKeys.set(resolved, key); + } + if (key === "") continue; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + path: resolved, + sources: [candidate.source], + threadCount: candidate.threadCount, + lastActiveAtMs: candidate.lastActiveAtMs, + }); + continue; + } + if (!existing.sources.includes(candidate.source)) { + existing.sources.push(candidate.source); + } + existing.threadCount += candidate.threadCount; + existing.lastActiveAtMs = + existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null + ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) + : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); + } + + // Resolve persisted roots too. A project and a transcript can name + // different symlinks to the same directory. + const shellSnapshot = yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + const importedProjectsByRoot = new Map(); + for (const project of shellSnapshot.projects) { + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); + importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); + } + + const candidates: Array = []; + for (const [key, entry] of merged.entries()) { + // Keep the path key for missing roots and use filesystem identity for + // aliases that resolve to the same directory. + const importedProject = + importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? + importedProjectsByRoot.get(key); + const candidatePath = importedProject?.workspaceRoot ?? entry.path; + candidates.push({ + path: candidatePath, + title: path.basename(candidatePath) || candidatePath, + ...(importedProject === undefined ? {} : { projectId: importedProject.id }), + sources: entry.sources, + threadCount: entry.threadCount, + lastActiveAt: + entry.lastActiveAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), + alreadyImported: importedProject !== undefined, + }); + } + + // Newest first, undated candidates last. + candidates.sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { + candidates, + scannedAt: DateTime.formatIso(yield* DateTime.now), + ...(truncated ? { truncated: true } : {}), + }; + }); + + const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( + workspaceRoot: string, + completedSources: ReadonlyArray, + ) { + const root = path.resolve(expandHomePath(workspaceRoot)); + const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; + const rootIdentity = yield* directoryIdentity(root); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; + + const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; + cachedCandidates = candidates; + + const eligibleTranscripts: Array<{ + readonly candidate: RawCandidate; + readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; + }> = []; + for (const candidate of candidates) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + + for (const transcript of candidate.transcripts) { + if ( + transcript.mtimeMs === null || + transcript.mtimeMs < cutoffMs || + transcript.mtimeMs > nowMs + ) { + continue; + } + eligibleTranscripts.push({ + candidate, + transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, + }); + } + } + + eligibleTranscripts.sort((left, right) => { + if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { + return right.transcript.mtimeMs - left.transcript.mtimeMs; + } + return left.transcript.filePath.localeCompare(right.transcript.filePath); + }); + + const completedByFile = Map.groupBy( + completedSources, + (source) => `${source.providerInstanceId}\0${source.filePath}`, + ); + const importedSessions = new Set(); + let bytesRemaining = MAX_IMPORT_BYTES; + let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; + let recordsRemaining = MAX_IMPORT_RECORDS; + return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( + Stream.mapEffect(({ candidate, transcript }) => + Effect.gen(function* () { + const completed = completedByFile.get( + `${candidate.providerInstanceId}\0${transcript.filePath}`, + ); + if ( + completed === undefined && + (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) + ) { + return Option.some({ _tag: "Skipped" }); + } + const stats = yield* statOption(transcript.filePath); + if (Option.isNone(stats) || stats.value.type !== "File") { + return Option.some({ _tag: "Skipped" }); + } + const identity = transcriptIdentity(transcript.filePath, stats.value); + const completedSource = completed?.find( + (source) => + source.provider === candidate.source && sameTranscriptIdentity(source, identity), + ); + if (completedSource !== undefined) { + const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; + if (importedSessions.has(sessionKey)) return Option.none(); + importedSessions.add(sessionKey); + return Option.some({ + _tag: "AlreadyImported", + source: completedSource, + }); + } + if ( + transcriptsRemaining === 0 || + recordsRemaining === 0 || + identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || + identity.size > bytesRemaining + ) { + return Option.some({ _tag: "Skipped" }); + } + // Reserve the whole file even if its read or parse fails. + transcriptsRemaining -= 1; + bytesRemaining -= identity.size; + const contents = yield* readTranscript(transcript.filePath, identity); + if (contents === null) { + return Option.some({ _tag: "Skipped" }); + } + const lines = splitTranscriptRecords(contents, recordsRemaining + 1); + if (lines.length > recordsRemaining) { + return Option.some({ _tag: "Skipped" }); + } + recordsRemaining -= lines.length; + + // A stable replacement file can belong to a different project than the cached candidate. + let snapshotCwd: string | null = null; + for (const line of lines) { + snapshotCwd = extractCwd(line); + if (snapshotCwd !== null) break; + } + if (snapshotCwd === null) { + return Option.some({ _tag: "Skipped" }); + } + const expandedCwd = expandHomePath(snapshotCwd.trim()); + if ( + !path.isAbsolute(expandedCwd) || + (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity + ) { + return Option.some({ _tag: "Skipped" }); + } + + const parsedThread = parseAgentSessionTranscript( + { + contents, + source: candidate.source, + providerInstanceId: candidate.providerInstanceId, + fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + lastActiveAtMs: transcript.mtimeMs, + }, + lines, + ); + if (parsedThread === null) { + return Option.some({ _tag: "Skipped" }); + } + + const source: AgentSessionImportSource = { + ...identity, + provider: parsedThread.source, + providerInstanceId: parsedThread.providerInstanceId, + providerSessionId: parsedThread.providerSessionId, + }; + const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; + if (importedSessions.has(sessionKey)) { + return Option.some({ _tag: "Duplicate", source }); + } + importedSessions.add(sessionKey); + return Option.some({ + _tag: "Importable", + thread: parsedThread, + source, + }); + }), + ), + Stream.map(Option.toArray), + Stream.flattenIterable, + ); + }); + + const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( + workspaceRoot, + completedSources = [], + ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); + + return AgentSessionScanner.of({ scan, recentThreads }); +}); + +export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 46fee8d8add6..6cc399dd4de2 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -41,6 +41,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 56cb1cb06f08..324284a3a4c7 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; @@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, instanceId, }); - const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath), + } satisfies ClaudeSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index d7fd1e9c5698..071fb20674a8 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; @@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, + binaryPath: expandHomePath(config.binaryPath), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 2ca44a2a5f0a..4676d780a530 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 81e799c9095e..082e20d7cb54 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -516,6 +516,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index af43e039e652..25dafa5ba040 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -24,7 +24,6 @@ */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Path from "effect/Path"; import { type ClaudeSettings, type CodexSettings, @@ -35,9 +34,12 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; @@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeTildeProviderFixtures = Effect.fn( + "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", +)(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = expandHomePath("~"); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: homePath, + prefix: ".t3-provider-path-test-", + }); + const codexPath = path.join(fixtureDir, "codex"); + const claudePath = path.join(fixtureDir, "claude"); + const claudeHomePath = path.join(fixtureDir, "claude-home"); + const codexScriptPath = path.join(fixtureDir, "codex-script.json"); + const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); + + yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), + path.join(fixtureDir, "codexCollabMockPeer.mjs"), + ); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexMultiAgentWire.json"), + path.join(fixtureDir, "codexMultiAgentWire.json"), + ); + yield* fileSystem.writeFileString( + codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), + ); + yield* fileSystem.chmod(codexPath, 0o755); + + yield* fileSystem.writeFileString( + claudePath, + [ + "#!/usr/bin/env node", + 'import * as NodeReadline from "node:readline";', + 'if (process.argv.includes("--version")) {', + ' process.stdout.write("claude 2.1.219\\n");', + " process.exit(0);", + "}", + "const lines = NodeReadline.createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [], agents: [], models: [],", + ' output_style: "default", available_output_styles: ["default"],', + ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(claudePath, 0o755); + yield* fileSystem.makeDirectory(claudeHomePath); + + const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + return { + codexBinaryPath: asTildePath(codexPath), + claudeBinaryPath: asTildePath(claudePath), + claudeHomePath, + codexScriptPath, + }; +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + + const fixtures = yield* makeTildeProviderFixtures(); + + const codexId = ProviderInstanceId.make("codex_tilde"); + const claudeId = ProviderInstanceId.make("claude_tilde"); + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "T3_CODEX_COLLAB_SCRIPT", + value: fixtures.codexScriptPath, + sensitive: false, + }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + [claudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver], + configMap, + }); + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + expect(codex).toBeDefined(); + expect(claude).toBeDefined(); + + const [codexSnapshot, claudeSnapshot] = yield* Effect.all( + [codex!.snapshot.refresh, claude!.snapshot.refresh], + { concurrency: "unbounded" }, + ); + expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + expect(claudeSnapshot).toMatchObject({ + status: "ready", + installed: true, + version: "2.1.219", + }); + }).pipe(Effect.provide(testLayer)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index f5b9be91650f..238265ec5a45 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4271,6 +4271,7 @@ const getBinding = vi.fn((threadId: ThreadId) => const boundedListing = makeProviderServiceLayer({ directory: { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), getBinding, listThreadIds, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 079b7f10ebfd..8b41bd3e518c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -4,9 +4,13 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; -import { it, assert } from "@effect/vitest"; -import { assertSome } from "@effect/vitest/utils"; +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type AgentSessionImportSource, +} from "@t3tools/contracts"; +import { assert, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +const importedSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "provider-session", + filePath: "/tmp/provider-session.jsonl", + size: 100, + mtimeMs: 1_000, + device: 1, + inode: 123, + birthtimeMs: 500, +} satisfies AgentSessionImportSource; + function makeDirectoryLayer(persistenceLayer: Layer.Layer) { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( + persistenceLayer, runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), NodeServices.layer, @@ -30,7 +47,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts and reads thread bindings", () => + it.effect("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: initialThreadId, }); const provider = yield* directory.getProvider(initialThreadId); assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(initialThreadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId: initialThreadId, provider: ProviderDriverKind.make("codex"), }); @@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: nextThreadId, }); const updatedBinding = yield* directory.getBinding(nextThreadId); @@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } const threadIds = yield* directory.listThreadIds(); - assert.deepEqual(threadIds, [nextThreadId]); - })); + expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId])); + }), + ); - it("persists runtime fields and merges payload updates", () => + it.effect("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "starting", resumeCursor: { @@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "running", runtimePayload: { @@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL activeTurnId: "turn-1", }); } - })); + }), + ); + + it.effect("keeps the existing binding when an insert conflicts", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-insert-conflict"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + + yield* directory.upsert( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "stopped", + resumeCursor: { threadId: "stale-provider-thread" }, + }, + { onConflict: "ignore" }, + ); + + const binding = yield* directory.getBinding(threadId); + expect(Option.getOrThrow(binding)).toMatchObject({ + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + }), + ); + + it.effect("records source files without replacing the current provider session", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = { ...importedSource, providerSessionId: "record-source" }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" }; + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claude-current"), + status: "running", + resumeCursor: { resume: "current-native-session" }, + runtimePayload, + }); + const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + + yield* directory.recordImportedTranscript({ threadId, source }); + const replacement = { ...source, size: 200, mtimeMs: 2_000 }; + yield* directory.recordImportedTranscript({ threadId, source: replacement }); + const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondFile }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...before, + runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] }, + }); + }), + ); + + it.effect("does not create a binding when recording an imported transcript", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:missing-source-binding"); + + yield* directory.recordImportedTranscript({ threadId, source: importedSource }); + + expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true); + }), + ); + + it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const firstSource = { ...importedSource, providerSessionId: "stale-source" }; + const threadId = ThreadId.make( + `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`, + ); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "original-native-session" }, + runtimePayload: { cwd: "/tmp/stale-source-project" }, + }); + yield* directory.recordImportedTranscript({ threadId, source: firstSource }); + const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondSource }); + + yield* repository.upsert({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + runtimePayload: { + cwd: "/tmp/stale-source-project", + importedTranscripts: [firstSource, secondSource], + }, + }); + }), + ); + + it.effect("reserves imported source records for the atomic recording method", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + for (const onConflict of ["update", "ignore"] as const) { + const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const binding = { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }; + yield* directory.upsert( + { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } }, + { onConflict }, + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + cwd: "/tmp/project", + }); + + yield* directory.recordImportedTranscript({ threadId, source }); + yield* directory.upsert({ ...binding, runtimePayload: null }); + + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + importedTranscripts: [source], + }); + } + }), + ); - it("lists persisted bindings with metadata in oldest-first order", () => + it.effect("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }); - const bindings = yield* directory.listBindings(); + const bindings = (yield* directory.listBindings()).filter( + (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId, + ); assert.deepEqual(bindings, [ { threadId: olderThreadId, provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), adapterKey: "claudeAgent", runtimeMode: "approval-required", status: "starting", @@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL { threadId: newerThreadId, provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }, ]); - })); + }), + ); - it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; - const threadId = ThreadId.make("thread-provider-change"); + it.effect( + "resets adapterKey to the new provider when provider changes without an explicit adapter key", + () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-provider-change"); - yield* runtimeRepository.upsert({ - threadId, - providerName: "claudeAgent", - providerInstanceId: null, - adapterKey: "claudeAgent", - runtimeMode: "full-access", - status: "running", - lastSeenAt: "2026-01-01T00:00:00.000Z", - resumeCursor: null, - runtimePayload: null, - }); + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); - yield* directory.upsert({ - provider: ProviderDriverKind.make("codex"), - threadId, - }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + }); - const runtime = yield* runtimeRepository.getByThreadId({ threadId }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerName, "codex"); - assert.equal(runtime.value.adapterKey, "codex"); - } - })); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.providerName, "codex"); + assert.equal(runtime.value.adapterKey, "codex"); + } + }), + ); - it("rehydrates persisted mappings across layer restart", () => + it.effect("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); @@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, }); }).pipe(Effect.provide(directoryLayer)); @@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(threadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId, provider: ProviderDriverKind.make("codex"), }); @@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }).pipe(Effect.provide(directoryLayer)); NodeFS.rmSync(tempDir, { recursive: true, force: true }); - })); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 253a954d2102..29ec8d2ed168 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { + const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding, options) { const existing = yield* repository .getByThreadId({ threadId: binding.threadId }) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); @@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { }); } yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) + .upsert( + { + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }, + options, + ) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); @@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = ( + input, + ) => + repository + .recordImportedTranscript(input) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")), + ); + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getProvider, getBinding, listThreadIds, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1777544d8fb0..c680a8228e3b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -217,6 +217,7 @@ describe("ProviderSessionReaper", () => { getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f2837d..7d6bbe61a2aa 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it.effect.each([ + { value: "~/.account", tail: ".account" }, + { value: "~\\.account\\work", tail: ".account\\work" }, + ])("expands configured provider homes set to $value", ({ value, tail }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnv = { + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }; + const environment = mergeProviderInstanceEnvironment( + [ + { name: "CODEX_HOME", value, sensitive: false }, + { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, + { name: "CUSTOM_VALUE", value, sensitive: false }, + ], + baseEnv, + ); + + expect(environment).toEqual({ + CODEX_HOME: path.join(NodeOS.homedir(), tail), + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), + CUSTOM_VALUE: value, + }); + expect(baseEnv).toEqual({ + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("leaves inherited provider homes unchanged", () => { + const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; + + expect( + mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], + baseEnv, + ), + ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); + }); + it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e6..77c0c6c2dc88 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,7 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { expandHomePath } from "../pathExpansion.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { - next[variable.name] = variable.value; + // Child processes do not apply shell expansion to environment values. + next[variable.name] = + variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" + ? expandHomePath(variable.value) + : variable.value; } return next; } diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f7a3..9dbafd3e804e 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,4 +1,5 @@ import type { + AgentSessionImportSource, ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, @@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError = | ProviderValidationError | ProviderSessionDirectoryPersistenceError; +export interface ProviderSessionDirectoryUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + export interface ProviderSessionDirectoryShape { readonly upsert: ( binding: ProviderRuntimeBinding, + options?: ProviderSessionDirectoryUpsertOptions, ) => Effect.Effect; + /** Record an imported file without changing the current provider session. */ + readonly recordImportedTranscript: (input: { + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }) => Effect.Effect; + readonly getProvider: ( threadId: ThreadId, ) => Effect.Effect; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 4e6d1b261947..fa567d75cf8a 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,6 +57,14 @@ rl.on("line", (line) => { }); return; } + if (method === "account/read") { + write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + return; + } + if (method === "skills/list" || method === "model/list") { + write({ id, result: { data: [] } }); + return; + } if (method === "thread/start") { write({ id, result: fixture.responses.threadStart }); return; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index ee23cbffaf0d..59049500729d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); }); - it("does not publish start intents, streaming content, or non-awareness activity events", () => { + it("does not publish imported, start-intent, streaming, or non-awareness events", () => { const now = "2026-05-25T00:00:00.000Z"; const base = { sequence: 1, @@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: "thread-1" as ThreadId, occurredAt: now, + metadata: {}, }; expect( @@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, } as unknown as OrchestrationEvent), ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); }); it("deduplicates awareness state updates whose only change is their event timestamp", () => { @@ -400,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), ); - it.effect("keeps the orchestration listener armed until relay config is installed", () => + it.effect("keeps the listener armed and skips imported thread work", () => Effect.scoped( Effect.gen(function* () { const events = yield* Queue.unbounded(); const threadShellRequested = yield* Deferred.make(); + const releaseThreadShell = yield* Deferred.make(); + const threadShellRequests: Array = []; + let fetchCallCount = 0; const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; const threadId = "thread-1" as ThreadId; + const importedThreadId = "import:codex:session-1" as ThreadId; const environmentId = "env-1" as EnvironmentId; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + fetchCallCount += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + const project = { id: projectId, title: "T3 Code", @@ -473,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { getShellSnapshot: () => Effect.succeed({ snapshotSequence: 1, - projects: [project], - threads: [thread], + projects: [], + threads: [], updatedAt: now, } satisfies OrchestrationShellSnapshot), - getThreadShellById: () => - Deferred.succeed(threadShellRequested, undefined).pipe( - Effect.ignore, - Effect.as(Option.some(thread)), - ), + getThreadShellById: (requestedThreadId: ThreadId) => + Effect.gen(function* () { + threadShellRequests.push(requestedThreadId); + if (requestedThreadId !== threadId) return Option.none(); + yield* Deferred.succeed(threadShellRequested, undefined); + yield* Deferred.await(releaseThreadShell); + return Option.some(thread); + }), getProjectShellById: () => Effect.succeed(Option.some(project)), } as unknown as ProjectionSnapshotQueryShape; @@ -511,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { yield* Effect.gen(function* () { const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; yield* relay.start(); - yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); yield* Queue.offer(events, { - type: "thread.activity-appended", + type: "thread.created", sequence: 1, + eventId: "evt-import-created", + commandId: CommandId.make("cmd-import-created"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.settled", + sequence: 2, + eventId: "evt-import-settled", + commandId: CommandId.make("cmd-import-settled"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.activity-appended", + sequence: 3, eventId: "evt-1", commandId: CommandId.make("cmd-1"), aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { @@ -532,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } as unknown as OrchestrationEvent); yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); + expect(threadShellRequests).toEqual([threadId]); + expect(fetchCallCount).toBe(0); + yield* Deferred.succeed(releaseThreadShell, undefined); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -692,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 3dd0df642ce8..8d7b9e98361e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { } export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { + if (event.metadata.historyImport === true) { + return false; + } switch (event.type) { case "thread.message-sent": case "thread.turn-start-requested": diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bebde7eded76..cfebfcf157c3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -36,6 +36,7 @@ import { type ProviderInstallState, ProviderSetupError, ResolvedKeybindingRule, + type ServerLifecycleStreamEvent, ThreadId, TurnId, WS_METHODS, @@ -91,6 +92,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationShellSnapshot), ); +const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; @@ -127,6 +129,7 @@ import { AntigravityInstallationError, } from "./provider/AntigravityInstallation.ts"; import type { ProviderInstance } from "./provider/ProviderDriver.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -513,6 +516,9 @@ const buildAppUnderTest = (options?: { projectSetupScriptRunner?: Partial< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; + providerSessionDirectory?: Partial< + ProviderSessionDirectory.ProviderSessionDirectory["Service"] + >; terminalManager?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; @@ -785,6 +791,13 @@ const buildAppUnderTest = (options?: { managedDirectory: "unused-test-antigravity-runtime", ...options?.layers?.antigravityInstallation, }), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ + upsert: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + ...options?.layers?.providerSessionDirectory, + }), ), ), Layer.provide( @@ -973,6 +986,7 @@ const buildAppUnderTest = (options?: { }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.succeed([]), getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), @@ -5332,6 +5346,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps agent session import project failures structured over websocket rpc", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const projectId = ProjectId.make("missing-import-project"); + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip), + ), + ); + + assert.equal(error._tag, "AgentSessionImportProjectNotFoundError"); + if (error._tag === "AgentSessionImportProjectNotFoundError") { + assert.equal(error.projectId, projectId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("returns scanner skip counts over websocket rpc", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-codex-", + }); + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-workspace-", + }); + const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31"); + const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl"); + yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + transcriptPath, + encodeTestJson({ + timestamp: "2026-08-31T12:00:00.000Z", + type: "session_meta", + payload: { id: "rpc-skipped-session", cwd: workspaceRoot }, + }), + ); + yield* fileSystem.utimes(transcriptPath, 0, 0); + + const projectId = ProjectId.make("agent-import-rpc-project"); + const project = { + id: projectId, + title: "Agent import RPC", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-31T12:00:00.000Z", + updatedAt: "2026-08-31T12:00:00.000Z", + } as const; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome }, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + }, + }), + }, + projectionSnapshotQuery: { + getProjectShellById: (requestedProjectId) => + Effect.succeed( + requestedProjectId === projectId ? Option.some(project) : Option.none(), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const scan = yield* client[WS_METHODS.agentSessionsScan]({}); + assert.deepEqual( + scan.candidates.map((candidate) => candidate.path), + [workspaceRoot], + ); + return yield* client[WS_METHODS.agentSessionsImport]({ projectId }); + }), + ), + ); + + assert.deepEqual(result, { importedCount: 0, skippedCount: 1 }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads Codex thread feedback through websocket rpc", () => Effect.gen(function* () { const input = { @@ -6239,6 +6350,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const streamSubscribed = yield* Deferred.make(); + const snapshotPublished = yield* Deferred.make(); + const bootstrapProjectId = ProjectId.make("project-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-bootstrap"); + const snapshotEvent = { + version: 1 as const, + sequence: 1, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "pending" as const, + }, + }; + const gapEvent = { + version: 1 as const, + sequence: 2, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "complete" as const, + bootstrapProjectId, + bootstrapThreadId, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + }, + }; + const sentinelEvent = { + version: 1 as const, + sequence: 3, + type: "ready" as const, + payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor }, + }; + const liveStream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(pubsub); + yield* Deferred.succeed(streamSubscribed, undefined); + return Stream.fromSubscription(subscription); + }), + ); + + yield* buildAppUnderTest({ + layers: { + serverLifecycleEvents: { + snapshot: PubSub.publish(pubsub, gapEvent).pipe( + Effect.andThen(Deferred.succeed(snapshotPublished, undefined)), + Effect.as({ sequence: 1, events: [snapshotEvent] }), + ), + stream: liveStream, + }, + }, + }); + + yield* Effect.gen(function* () { + yield* Deferred.await(snapshotPublished); + yield* Deferred.await(streamSubscribed); + yield* PubSub.publish(pubsub, sentinelEvent); + }).pipe(Effect.forkScoped); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "welcome"); + assert.equal(first?.sequence, 1); + if (first?.type !== "welcome") { + assert.fail("expected the pending bootstrap event"); + } + assert.equal(first.payload.bootstrapStatus, "pending"); + assert.equal(second?.type, "welcome"); + assert.equal(second?.sequence, 2); + if (second?.type !== "welcome") { + assert.fail("expected the bootstrap completion event"); + } + assert.equal(second.payload.bootstrapStatus, "complete"); + assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId); + assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId); + assert.equal(second.payload.bootstrapProjectCreated, true); + assert.equal(second.payload.bootstrapThreadCreated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 2c95a6163acd..e46c2a8ca010 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -151,6 +151,7 @@ it.effect("marks active running sessions that have persisted resume state", () = ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)( firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, ), ), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { ); }, upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure }), ), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -596,6 +601,7 @@ it.effect( }), ), upsert: () => Effect.fail(writeFailure), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure", directory: { getBinding: () => Effect.succeed(Option.none()), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -754,6 +762,7 @@ for (const scenario of [ Effect.sync(() => { upserts.push(binding); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -827,6 +836,7 @@ for (const preparedStatus of [ if (binding.status !== "starting" || sends.length === 0) return; yield* Deferred.succeed(cleared, undefined); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => @@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( Effect.sync(() => { binding = next; }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fddf618cb13b..8cba52552270 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -155,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -181,6 +182,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa assert.deepStrictEqual(targets, { bootstrapProjectId, bootstrapThreadId, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, }); assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }); @@ -212,6 +215,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -237,6 +241,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadCreated, true); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), @@ -250,6 +256,60 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }), ); +it.effect( + "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure", + () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), + getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused thread replay stats"), + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadId, undefined); + assert.equal(targets.bootstrapThreadCreated, undefined); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]); + }), +); + it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -278,6 +338,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -309,3 +370,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.fail("bootstrap failed"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles unexpected defects", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.die("bootstrap defect"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({})); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index ea3670f08c9f..2a12dbb3637c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -194,6 +194,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; + let bootstrapProjectCreated = false; + let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { @@ -216,45 +218,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { workspaceRoot: serverConfig.cwd, createdAt, }); + bootstrapProjectId = nextProjectId; + bootstrapProjectCreated = true; } else { nextProjectId = existingProject.value.id; + bootstrapProjectId = nextProjectId; nextThreadModelSelection = existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); } - const existingThreadId = - yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); - if (Option.isNone(existingThreadId)) { - const createdAt = DateTime.formatIso(yield* DateTime.now); - const createdThreadId = ThreadId.make(yield* randomUUID); - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: CommandId.make(yield* randomUUID), - threadId: createdThreadId, - projectId: nextProjectId, - title: "New thread", - modelSelection: nextThreadModelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - }); - bootstrapProjectId = nextProjectId; - bootstrapThreadId = createdThreadId; - } else { - bootstrapProjectId = nextProjectId; - bootstrapThreadId = existingThreadId.value; - } + yield* Effect.gen(function* () { + const existingThreadId = + yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); + if (Option.isNone(existingThreadId)) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + const createdThreadId = ThreadId.make(yield* randomUUID); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* randomUUID), + threadId: createdThreadId, + projectId: nextProjectId, + title: "New thread", + modelSelection: nextThreadModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + bootstrapThreadId = createdThreadId; + bootstrapThreadCreated = true; + } else { + bootstrapThreadId = existingThreadId.value; + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: nextProjectId, + cause, + }), + ), + ); }); } return { ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), + ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}), + ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}), } as const; }); +export const completeAutoBootstrapWelcome = ( + bootstrap: Effect.Effect, +) => + bootstrap.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe( + Effect.as({ bootstrapStatus: "complete" as const }), + ), + onSuccess: (targets) => + Effect.succeed({ + ...targets, + bootstrapStatus: "complete" as const, + }), + }), + ); + const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -839,36 +875,31 @@ export const make = (options?: StartupOptions) => runStartupPhase( "welcome.autobootstrap", Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(Crypto.Crypto, crypto), + const bootstrapCompletion = yield* completeAutoBootstrapWelcome( + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + ); + + yield* Effect.logDebug( + "startup phase: publishing completed bootstrap welcome event", + { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + ...bootstrapCompletion, + }, ); - if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { - return; - } - - yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); yield* lifecycleEvents.publish({ version: 1, type: "welcome", payload: { environment, ...welcomeBase, - ...bootstrapTargets, + ...bootstrapCompletion, }, }); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("startup auto-bootstrap welcome failed", { - cause, - }), - ), - ), + }).pipe(Effect.ignoreCause({ log: true })), ), ); } @@ -914,7 +945,11 @@ export const make = (options?: StartupOptions) => lifecycleEvents.publish({ version: 1, type: "welcome", - payload: { environment, ...welcomeBase }, + payload: { + environment, + ...welcomeBase, + bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", + }, }), ); yield* options?.activate ?? Effect.void; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4526e2c988a4..6e837308e8cb 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("materializes provider secrets for terminal environment resolution", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("codex_terminal"); + + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true }, + ], + config: { homePath: "~/.codex-terminal" }, + }, + }, + }); + + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: instanceId, + env: undefined, + }); + const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); + + assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret"); + assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/); + assert.notInclude(persisted, "sk-terminal-secret"); + assert.include(persisted, '"valueRedacted": true'); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index deea39631788..e480e11588b0 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,9 +7,14 @@ import { type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, + ProviderDriverKind, + ProviderInstanceId, + ServerSettingsError, + TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -23,11 +28,16 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -215,6 +225,9 @@ interface CreateManagerOptions { maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; } interface ManagerFixture { @@ -259,6 +272,9 @@ const createManager = ( ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.resolveProviderInstanceEnvironment !== undefined + ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } + : {}), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1736,6 +1752,26 @@ it.layer( }), ); + it.effect("expands provider home paths passed to setup terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5); + + yield* manager.open({ + ...openInput(), + env: { + CODEX_HOME: "~/.codex-work", + CLAUDE_CONFIG_DIR: "~/.claude-work", + CUSTOM_ACCOUNT: "~/leave-this-value-alone", + }, + }); + + const environment = ptyAdapter.spawnInputs[0]?.env; + expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); + expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); + expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); + }), + ); + it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -1822,6 +1858,382 @@ it.layer( }), ); + it.effect("resolves a provider instance environment before spawning", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: (requestedId, env) => + Effect.succeed({ + ...env, + PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", + CODEX_HOME: "/accounts/codex-work", + }), + }); + + const snapshot = yield* manager.open( + openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), + ); + + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); + expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); + expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(snapshot).not.toHaveProperty("providerInstanceId"); + }), + ); + + it.effect("fails closed when a provider instance is missing", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + + it.effect("preserves the settings failure when provider environment resolution fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const settingsCause = new Error("secret store read failed"); + const settingsError = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-secret", + providerInstanceId, + environmentVariable: "OPENROUTER_API_KEY", + cause: settingsCause, + }); + const serverSettings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(settingsError), + updateSettings: () => Effect.fail(settingsError), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }); + + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: providerInstanceId, + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId, + }); + expect(error.cause).toBe(settingsError); + expect(error.message).not.toContain(settingsError.message); + expect(error.message).not.toContain("OPENROUTER_API_KEY"); + }), + ); + + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "configured_home", + env: undefined, + }); + + expect(environment[variable]).toBe(path.resolve(expectedHome)); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, + }, + }, + }), + ), + ), + ); + + it.effect("resolves the legacy Codex default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { codex: { homePath: "~/.codex-legacy" } }, + }), + ), + ), + ); + + it.effect("resolves the legacy Claude default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "claudeAgent", + env: undefined, + }); + + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + }), + ), + ), + ); + + it.effect("prefers an explicit default instance over legacy provider settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + config: { homePath: "~/.codex-explicit" }, + }, + }, + }), + ), + ), + ); + + it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex_unknown", + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderInstanceNotFoundError", + providerInstanceId: "codex_unknown", + }); + }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), + ); + + it.effect("restarts a running terminal when the resolved provider environment changes", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerSecret = "first-secret"; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: () => + Effect.succeed({ PROVIDER_SECRET: providerSecret }), + }); + + yield* manager.open(openInput({ providerInstanceId })); + providerSecret = "second-secret"; + yield* manager.open(openInput({ providerInstanceId })); + + expect(ptyAdapter.processes[0]?.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); + }), + ); + + it.effect("restarts with current provider secrets and clears bounded history", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_restart"); + const { manager, ptyAdapter, logsDir } = yield* createManager(2, { + historyByteLimit: 8, + resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + }); + const homePath = path.join(logsDir, "codex"); + const updateSecret = (value: string) => + serverSettings.updateSettings({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath }, + environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], + }, + }, + }); + const input = { + providerInstanceId, + env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, + }; + const outputProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* updateSecret("first-secret"); + yield* manager.restart(restartInput(input)); + const firstProcess = ptyAdapter.processes[0]!; + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); + firstProcess.emitData("discarded\nold-one\nold-two\n"); + yield* Deferred.await(outputProcessed); + expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); + + yield* updateSecret("second-secret"); + const restarted = yield* manager.restart(restartInput(input)); + + expect(firstProcess.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ + PROVIDER_SECRET: "second-secret", + CODEX_HOME: homePath, + CLIENT_FLAG: "1", + }); + expect(restarted.history).toBe(""); + expect(restarted.status).toBe("running"); + expect(restarted).not.toHaveProperty("env"); + expect(restarted).not.toHaveProperty("providerInstanceId"); + const logPath = yield* historyLogPath(logsDir); + expect(yield* readFileString(logPath)).toBe(""); + + ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(logPath)).toBe("new-two\n"); + }).pipe( + Effect.provide( + ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), + ), + ), + ), + ), + ); + + it.effect("attaches to a running provider terminal without resolving the provider again", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerAvailable = true; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + providerAvailable + ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) + : Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + yield* manager.open(openInput({ providerInstanceId })); + providerAvailable = false; + const events: TerminalAttachStreamEvent[] = []; + + const unsubscribe = yield* manager.attachStream( + { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, + (event) => Effect.sync(() => events.push(event)), + ); + unsubscribe(); + + expect(events[0]?.type).toBe("snapshot"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + }), + ); + + it.effect("fails closed when attaching would create a missing provider terminal", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager + .attachStream(openInput({ providerInstanceId }), () => Effect.void) + .pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index f04e3c2d897b..d9bdc6bcd92a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -15,6 +15,8 @@ import { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -31,6 +33,9 @@ import { type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, + ClaudeSettings, + CodexSettings, + ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../observability/Metrics.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -69,6 +80,8 @@ export { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv( } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; + spawnEnv[key] = + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; } } // Both PTY backends feed truecolor-capable terminal clients. @@ -1306,17 +1322,78 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + resolveProviderInstanceEnvironment?: ( + providerInstanceId: string, + env: Record | undefined, + ) => Effect.Effect< + Record, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + >; } +export const resolveProviderInstanceTerminalEnvironment = Effect.fn( + "terminal.resolveProviderInstanceTerminalEnvironment", +)(function* (input: { + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly path: Path.Path; + readonly rawProviderInstanceId: string; + readonly env: Record | undefined; +}) { + const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); + const settings = yield* input.serverSettings.getSettings.pipe( + Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), + ); + const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (instance === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); + } + + let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + if (instance.driver === "codex") { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isSome(config)) { + const layout = yield* resolveCodexHomeLayout(config.value).pipe( + Effect.provideService(Path.Path, input.path), + ); + if (layout.effectiveHomePath) + resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + } + } else if (instance.driver === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isSome(config)) { + resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( + Effect.provideService(Path.Path, input.path), + ); + } + } + + return Object.fromEntries( + Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +}); + export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const resolveProviderInstanceEnvironment = Effect.fn( + "terminal.resolveProviderInstanceEnvironment", + )((rawProviderInstanceId: string, env: Record | undefined) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + ); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + resolveProviderInstanceEnvironment, }); }); @@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( + function* ( + input: Input, + ): Effect.fn.Return< + Input, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + > { + if (input.providerInstanceId === undefined) return input; + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + return { ...input, env }; + }, + ); // One process-table snapshot per poll tick, shared across every terminal. // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and // can exhaust the PID space on hosts with many sessions (#6332). @@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const open: TerminalManager["Service"]["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } const session = existing.value; @@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } if ( @@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const restartResolved = (input: TerminalRestartInput) => + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); + const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)), ); const close: TerminalManager["Service"]["close"] = (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5ecdd341c952..6261f7bc5287 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -53,6 +53,7 @@ import { type RelayClientInstallProgressEvent, ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -96,6 +97,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -119,6 +121,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; +import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -512,6 +516,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const providerAuth = yield* ProviderAuthService; const providerInstances = yield* ProviderInstanceRegistry; @@ -560,6 +565,7 @@ const makeWsRpcLayer = ( return true; }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2332,6 +2338,31 @@ const makeWsRpcLayer = ( deletePendingAttachment(input.attachmentId), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.agentSessionsImport]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsImport, + importRecentAgentThreads(input).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService( + ProviderSessionDirectory.ProviderSessionDirectory, + providerSessionDirectory, + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2746,11 +2777,18 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerLifecycle, Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + lifecycleEvents.stream.pipe( + Stream.runForEach((event) => Queue.offer(liveBuffer, event)), + ), + { startImmediately: true }, + ); const snapshot = yield* lifecycleEvents.snapshot; const snapshotEvents = Array.from(snapshot.events).toSorted( (left, right) => left.sequence - right.sequence, ); - const liveEvents = lifecycleEvents.stream.pipe( + const liveEvents = Stream.fromQueue(liveBuffer).pipe( Stream.filter((event) => event.sequence > snapshot.sequence), ); return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); @@ -2877,6 +2915,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( previewAutomationBroker, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // One server-lifetime service means clients share the same PR caches, and a WS diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..dfe2b51d0400 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("keeps manual token submission pending until the session is authenticated", async () => { + vi.useFakeTimers(); + let authenticated = false; + let settled = false; + try { + const testApi = await installAuthApi({ + session: () => + authenticated + ? authenticatedSession(LOOPBACK_AUTH) + : unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { submitServerAuthCredential } = await import("./environments/primary"); + + const submission = submitServerAuthCredential("retry-token").finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBe(1); + expect(settled).toBe(false); + + authenticated = true; + await vi.advanceTimersByTimeAsync(100); + await expect(submission).resolves.toBeUndefined(); + expect(testApi.calls.session).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("fails manual token submission when the session is not established", async () => { + vi.useFakeTimers(); + try { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const submission = submitServerAuthCredential("retry-token"); + const failure = submission.then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it("rejects a blank pairing token with a structured validation error", async () => { const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = await import("./environments/primary/auth"); diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx new file mode 100644 index 000000000000..4a241befef74 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -0,0 +1,200 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type ClientSettings, + type DesktopPreviewBridge, +} from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), + createTab: vi.fn(), + closeTab: vi.fn(), + registerWebview: vi.fn(), + getPreviewConfig: vi.fn(), + activeRecordings: new Set(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { + createTab: mocks.createTab, + closeTab: mocks.closeTab, + registerWebview: mocks.registerWebview, + getPreviewConfig: mocks.getPreviewConfig, + }, +})); + +vi.mock("~/components/preview/usePreviewBridge", () => ({ + usePreviewBridge: () => undefined, +})); + +vi.mock("./browserRecording", () => ({ + useActiveBrowserRecordingTabIds: () => mocks.activeRecordings, + stopBrowserRecording: async () => null, +})); + +import { + __resetClientSettingsPersistenceForTests, + ensureClientSettingsHydrated, +} from "~/hooks/useSettings"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import * as desktopTabLifetime from "./desktopTabLifetime"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +let renderer: ReactTestRenderer | undefined; + +function deferred() { + let resolve!: (value: A) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + mocks.getClientSettings.mockReset(); + mocks.setClientSettings.mockReset().mockResolvedValue(undefined); + mocks.createTab.mockReset().mockResolvedValue(undefined); + mocks.closeTab.mockReset().mockResolvedValue(undefined); + mocks.registerWebview.mockReset().mockResolvedValue(undefined); + mocks.getPreviewConfig.mockReset().mockResolvedValue({ + partition: "persist:t3-preview-work", + webPreferences: "contextIsolation=yes", + preloadUrl: null, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 0), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + vi.useFakeTimers(); + await act(() => renderer?.unmount()); + renderer = undefined; + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("HostedBrowserWebview settings hydration", () => { + it("starts a retained background tab only after a settings read succeeds on retry", async () => { + const firstRead = deferred(); + const retryRead = deferred(); + const tabCreation = deferred(); + mocks.getClientSettings + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(retryRead.promise); + mocks.createTab.mockReturnValueOnce(tabCreation.promise); + const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab"); + const createGuest = vi.fn((_attributes: unknown) => + Object.assign(new EventTarget(), { getWebContentsId: () => 41 }), + ); + const threadRef = { + environmentId: EnvironmentId.make("host-settings-retry"), + threadId: ThreadId.make("thread-settings-retry"), + }; + const runtimeTabId = "retained-background-tab"; + useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? createGuest(element.props) + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + const failure = new Error("Saved settings are unavailable"); + await act(async () => { + const hydration = ensureClientSettingsHydrated(); + firstRead.reject(failure); + await expect(hydration).rejects.toBe(failure); + }); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + let retry!: Promise; + await act(() => { + retry = ensureClientSettingsHydrated(); + }); + expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + await act(async () => { + retryRead.resolve({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", + }); + await retry; + }); + + expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); + expect(createGuest).toHaveBeenCalledOnce(); + expect(createGuest).toHaveBeenCalledWith( + expect.objectContaining({ + partition: "persist:t3-preview-work", + src: "https://example.com", + }), + ); + expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { + zoomFactor: 1.25, + colorScheme: "dark", + }); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + + await act(async () => { + tabCreation.resolve(); + await tabCreation.promise; + }); + expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); + expect(mocks.closeTab).not.toHaveBeenCalled(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0f01960ce52b..42d5bcfb35b8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { zoomFactor, profileId, } = props; + const clientSettingsHydrated = useClientSettingsHydrated(); const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { + if (!clientSettingsHydrated) return; crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; - }, [runtimeTabId]); + }, [clientSettingsHydrated, runtimeTabId]); const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { useEffect(() => { const webview = webviewRef.current; const bridge = previewBridge; - if (!webview || !config || !bridge) return; + if (!clientSettingsHydrated || !webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; const register = () => { @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, initialSrc, runtimeTabId, webviewGeneration]); + }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index bac9600c182b..ed86cde1c9c8 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ getClientSettings: () => settings.current, useClientSettings: () => undefined, - ensureClientSettingsHydrated: () => Promise.resolve(), + ensureClientSettingsHydrated: vi.fn(async () => undefined), })); -const { getBrowserDefaults } = await import("./browserDefaults"); +const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); const withDefaultProfile = (browserDefaultProfileId: string) => { settings.current = { @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { ); }); }); + +describe("resolveBrowserDefaults", () => { + it("rejects failed reads and uses the saved profile after a successful retry", async () => { + withDefaultProfile("work"); + settings.current.browserDefaultZoomFactor = 1.25; + settings.current.browserDefaultAppearance = "dark"; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserDefaults()).rejects.toBe(failure); + await expect(resolveBrowserDefaults()).resolves.toMatchObject({ + viewport: { _tag: "fill" }, + zoomFactor: 1.25, + appearance: "dark", + autoShowFloatingPreview: true, + profileId: "work", + }); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index eaae409568a2..6141b1a52fa9 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults { * Opening a preview is asynchronous anyway, and before hydration the snapshot * is the schema defaults rather than the user's — a tab opened in that window * would be born at the wrong viewport, zoom and appearance and never corrected. + * Read failures reject so a new tab cannot use the wrong profile or viewport. */ export async function resolveBrowserDefaults(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 94f97001c96f..a60362c43bd5 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { BrowserLinkTarget } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveLinkTarget } from "./browserLinkTarget"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + +import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; + +const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); + +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: vi.fn(async () => undefined), + getClientSettings: () => settings, +})); const click = { metaKey: false, ctrlKey: false }; @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { } }); }); + +describe("resolveBrowserLinkTargetPreference", () => { + it.each(["system", "app"] as const)( + "rejects failed reads instead of using the current %s preference", + async (preference) => { + settings.browserLinkTarget = preference; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); + await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); + }, + ); +}); diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index d03775572747..7ecffb4593d5 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { * hydration the snapshot is the schema default ("system"), so a link clicked * in the first moments after launch would ignore a persisted "app" — opening * is asynchronous anyway, so waiting costs nothing the user can see. + * Read failures reject rather than choosing a browser without the saved preference. */ export async function resolveBrowserLinkTargetPreference(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 49145f314e98..5cfe614f2985 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const { clientSettings, events, @@ -241,6 +243,35 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); + it("clears a failed settings read before retrying recording", async () => { + const tabId = "settings-read-failure-tab"; + const error = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); + + await expect(startBrowserRecording(tabId)).rejects.toBe(error); + + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + expect(animationFrameCount).toBe(0); + expect(startScreencast).not.toHaveBeenCalled(); + expect(stopScreencast).not.toHaveBeenCalled(); + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(FakeMediaRecorder.instances).toHaveLength(0); + + clientSettings.browserRecordingFrameRate = 60; + await startBrowserRecording(tabId); + + expect(getDisplayMedia).toHaveBeenCalledWith({ + audio: false, + video: { frameRate: { max: 60 } }, + }); + await stopBrowserRecording(tabId); + + expect(startScreencast).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + }); + it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValueOnce({ diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 73bc2708ddf6..c7825961abbc 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -516,10 +516,12 @@ export async function startBrowserRecording( activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); try { - const frameRatePromise = ensureClientSettingsHydrated().then( - () => getClientSettings().browserRecordingFrameRate, - ); - const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); + await ensureClientSettingsHydrated().catch((cause: unknown) => { + clearActiveRecording(recording); + throw cause; + }); + const frameRate = getClientSettings().browserRecordingFrameRate; + await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. // Only a contended start can be cancelled before it reaches native capture. diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 80bfa0d275d7..c5338ecf4ccf 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, + DEFAULT_CLIENT_SETTINGS, EnvironmentId, ThreadId, } from "@t3tools/contracts"; @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; +import * as browserDefaults from "./browserDefaults"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; -/** Client settings are unset in tests, so creation carries the schema defaults. */ +/** Tests load default settings unless they select other preferences. */ const DEFAULT_TAB_STATE = { zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; describe("desktopTabLifetime", () => { beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); closeTab.mockClear(); createTab.mockClear(); stopBrowserRecording.mockClear(); @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { + vi.useFakeTimers(); + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const failed = acquireDesktopTab("tab_settings_retry"); + + await expect(failed.ready).rejects.toBe(failure); + expect(createTab).not.toHaveBeenCalled(); + failed.release(); + await vi.advanceTimersByTimeAsync(0); + + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + }); + createTab.mockResolvedValueOnce(undefined); + const retry = acquireDesktopTab("tab_settings_retry"); + await retry.ready; + + expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { + zoomFactor: 1.25, + colorScheme: "dark", + }); + retry.release(); + await vi.advanceTimersByTimeAsync(0); }); it("shares tab creation readiness across concurrent leases", async () => { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index f506e42e73e5..a320e3ba34da 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( readonly message: string; }> {} +export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{ + readonly cause: unknown; +}> { + override get message(): string { + return "Saved browser settings could not be loaded."; + } +} + export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; readonly input: PreviewOpenInput; @@ -47,8 +55,13 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; -}): Promise> { - const defaults = await resolveBrowserDefaults(); +}): Promise> { + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -82,7 +95,12 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError + > +> { if (!isPreviewSupportedInRuntime()) { return AsyncResult.failure( Cause.fail( diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 0e9bf721f82d..2a1d122eedbf 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,5 +1,8 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; @@ -12,7 +15,7 @@ import { resolveBrowserLinkTargetPreference, resolveLinkTarget, } from "./browserLinkTarget"; -import { openUrlInPreview } from "./openFileInPreview"; +import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview"; const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; * * An in-app open that fails falls back to the system browser rather than * dropping the click: the user asked for the link, and the setting only says - * where it should go first. The returned promise rejects only when that - * fallback fails too, the same way `shell.openExternal` does. + * where it should go first. Failed settings reads reject without opening a + * browser. The promise also rejects if the system-browser fallback fails. */ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url: string, @@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( recordVisitForThread(targetThreadRef, url); return; } + const failure = squashAtomCommandFailure(result); + if (failure instanceof BrowserSettingsReadError) throw failure; console.error(result.cause); } const api = readLocalApi(); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index db69fe96c80a..a86177b48eb3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); - it("reports structured decode failures while preserving the fallback", async () => { + it.each(["not-json", '{"wordWrap":"invalid"}'])( + "does not treat invalid saved settings as absent: %s", + async (value) => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", value); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(() => readBrowserClientSettings()).toThrow( + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + }), + ); + expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); + }, + ); + + it("preserves saved settings across a transient read failure", async () => { const testWindow = getTestWindow(); - testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + const write = vi.spyOn(testWindow.localStorage, "setItem"); + const failure = new Error("storage unavailable"); + vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { + throw failure; + }); const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - expect(readBrowserClientSettings()).toBeNull(); - expect(consoleError).toHaveBeenCalledWith( - "Could not read persisted client settings.", + expect(() => readBrowserClientSettings()).toThrow( expect.objectContaining({ _tag: "LocalStorageOperationError", - operation: "decode", + operation: "read", storageKey: "t3code:client-settings:v1", - cause: expect.anything(), + cause: failure, }), ); + expect(readBrowserClientSettings()).toEqual(settings); + expect(write).not.toHaveBeenCalled(); }); it("defaults word wrap on and discards obsolete wrapping preferences", async () => { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 5c0ba7c6eccf..f39ea63c5a7c 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null { return null; } - try { - return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch (error) { - console.error("Could not read persisted client settings.", error); - return null; - } + return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); } export function writeBrowserClientSettings(settings: ClientSettings): void { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index af41324be6a7..55de831a52e7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -176,6 +176,7 @@ import { openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, + BrowserSettingsReadError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; @@ -2357,6 +2358,18 @@ function useChatMarkdownState({ } return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { if (result._tag === "Success") recordVisitForThread(threadRef, url); + else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link in browser", + description: error.message, + }), + ); + } + } return result; }); }, @@ -2789,14 +2802,14 @@ const CHAT_MARKDOWN_COMPONENTS = { } event.preventDefault(); event.stopPropagation(); - // The click was taken from the shell, so an in-app open that fails - // hands the link to the system browser instead of dropping it. + // Keep the link here if saved settings could not be read. void openExternalLinkInPreview(href).then((result) => { if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; reportMarkdownActionFailure( { operation: "open-link-in-preview", target: href }, result.cause, ); + if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return; void readLocalApi()?.shell.openExternal(href); }); }} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b27d66c7611d..11226371172f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -162,6 +162,7 @@ import { useThreadPreviewState, } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "../browser/openFileInPreview"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -3718,6 +3719,18 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, openPreview, ...(profileId === undefined ? {} : { profileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } }); }, [activeThreadRef, openPreview], diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index d9dcd6e79936..1624a739bb1a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,11 +1,98 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { shouldClearTerminalSelectionAction, shouldHandleTerminalExit, + terminalContextMenuItems, terminalSelectionLineRange, + terminalSelectionMenuItems, + terminalThemeFromApp, } from "./ThreadTerminalDrawer"; +describe("terminal selection menus", () => { + it("omits Add to chat when the terminal has no chat target", () => { + expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]); + expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([ + "add-to-chat", + "copy", + "paste", + ]); + + expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ + "copy", + ]); + expect( + terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), + ).toEqual(["copy", "paste"]); + }); +}); + +describe("terminalThemeFromApp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses terminal colors inherited by the mount instead of a light document theme", () => { + const root = { classList: { contains: () => false } }; + const body = {}; + const drawer = {}; + let canvasColor = "#000"; + const colors: Record = { + "#000": [0, 0, 0, 255], + "#fff": [255, 255, 255, 255], + "#ddd": [221, 221, 221, 255], + "#111": [17, 17, 17, 255], + }; + + vi.stubGlobal("document", { + documentElement: root, + body, + querySelector: () => drawer, + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ + clearRect: () => undefined, + fillRect: () => undefined, + get fillStyle() { + return canvasColor; + }, + set fillStyle(value: string) { + canvasColor = value; + }, + getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }), + }), + }), + }); + vi.stubGlobal("getComputedStyle", (element: object) => { + const local = element === drawer; + const values = local + ? { + "--terminal-background": "#000", + "--terminal-foreground": "#fff", + "--terminal-cursor": "#ddd", + "--terminal-selection-background": "rgba(255, 255, 255, 0.2)", + } + : { + "--terminal-background": "#fff", + "--terminal-foreground": "#111", + }; + return { + backgroundColor: local ? "#000" : "#fff", + color: local ? "#fff" : "#111", + colorScheme: local ? "dark" : "light", + getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "", + }; + }); + + const theme = terminalThemeFromApp(); + + expect(theme.background).toEqual({ r: 0, g: 0, b: 0 }); + expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 }); + expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 }); + }); +}); + describe("terminal selection actions", () => { it("clears a pending or currently owned menu when the selection disappears", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9f4956aae682..d9ddf9225bdf 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { type ContextMenuItem, + type ProviderInstanceId, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -41,6 +42,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s } export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { - const isDark = document.documentElement.classList.contains("dark"); - const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; - const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const drawerSurface = mountElement?.closest(".thread-terminal-drawer") ?? document.querySelector(".thread-terminal-drawer") ?? document.body; const drawerStyles = getComputedStyle(drawerSurface); + const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; + const colorScheme = themeStyles.colorScheme; + const isDark = + colorScheme === "dark" + ? true + : colorScheme === "light" + ? false + : document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const bodyStyles = getComputedStyle(document.body); - const themeStyles = getComputedStyle(document.documentElement); + const rootThemeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); - const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalBackground = readThemeColor( + themeStyles, + "--terminal-background", + readThemeColor(rootThemeStyles, "--terminal-background", background), + ); + const terminalForeground = readThemeColor( + themeStyles, + "--terminal-foreground", + readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), + ); const terminalCursor = readThemeColor( themeStyles, "--terminal-cursor", @@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: { export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; -/** Post-selection popup: just the two selection actions, always enabled. */ -export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { +/** Post-selection popup: available selection actions, always enabled. */ +export function terminalSelectionMenuItems(options?: { + canAddToChat?: boolean; +}): ContextMenuItem<"add-to-chat" | "copy">[] { return [ - { id: "add-to-chat", label: "Add to chat" }, + ...(options?.canAddToChat === false + ? [] + : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])), { id: "copy", label: "Copy" }, ]; } @@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c */ export function terminalContextMenuItems(options: { hasSelection: boolean; + canAddToChat?: boolean; }): ContextMenuItem[] { + const { hasSelection, canAddToChat = true } = options; return [ - ...terminalSelectionMenuItems().map((item) => ({ + ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({ ...item, - disabled: !options.hasSelection, + disabled: !hasSelection, })), { id: "paste", label: "Paste" }, ]; @@ -292,8 +315,9 @@ interface TerminalViewportProps { cwd: string; worktreePath?: string | null; runtimeEnv?: Record; + providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext?: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; visible: boolean; @@ -317,6 +341,7 @@ export function TerminalViewport({ cwd, worktreePath, runtimeEnv, + providerInstanceId, onSessionExited, onAddTerminalContext, focusRequestId, @@ -357,8 +382,9 @@ export function TerminalViewport({ onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { - onAddTerminalContext(selection); + onAddTerminalContext?.(selection); }); + const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); const readTerminalLabel = useEffectEvent(() => terminalLabel); const terminalFontFamily = useClientSettings((settings) => resolveTerminalFontPreference({ @@ -383,6 +409,7 @@ export function TerminalViewport({ cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), }, }); const writeTerminal = useEffectEvent((data: string) => @@ -635,7 +662,10 @@ export function TerminalViewport({ let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( - terminalContextMenuItems({ hasSelection: selectionAction !== null }), + terminalContextMenuItems({ + hasSelection: selectionAction !== null, + canAddToChat: canAddSelectionToChat(), + }), { x: event.clientX, y: event.clientY }, ); } catch (error) { @@ -648,7 +678,9 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - if (selectionAction) addSelectionToChat(selectionAction.selection); + if (selectionAction && canAddSelectionToChat()) { + addSelectionToChat(selectionAction.selection); + } return; case "copy": if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); @@ -675,7 +707,10 @@ export function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show(terminalSelectionMenuItems(), nextAction.position) + .show( + terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }), + nextAction.position, + ) .finally(() => { if (openSelectionMenuRequestIdRef.current === requestId) { openSelectionMenuRequestIdRef.current = null; @@ -686,7 +721,7 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - addSelectionToChat(nextAction.selection); + if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); return; case "copy": await copySelection(nextAction.clipboardText, requestId); @@ -768,6 +803,14 @@ export function TerminalViewport({ threadRef, openPreview, fallbackToBrowser, + }).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }); return; } diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx new file mode 100644 index 000000000000..e82135dc44bd --- /dev/null +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -0,0 +1,214 @@ +import type { Discovery } from "@t3tools/client-runtime/relay"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, type ButtonHTMLAttributes } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; + +const discovery = vi.hoisted(() => ({ + state: null as Discovery.RelayEnvironmentDiscoveryState | null, + listeners: new Set<() => void>(), + refreshCommand: Symbol("refresh"), + registerCommand: Symbol("register"), + refresh: vi.fn<() => Promise>>(), + register: vi.fn(), + listEnvironments: vi.fn<() => Promise>(), +})); + +vi.mock("~/state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: discovery.refreshCommand }, +})); +vi.mock("~/connection/catalog", () => ({ + environmentCatalog: { register: discovery.registerCommand }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === discovery.refreshCommand ? discovery.refresh : discovery.register, +})); +vi.mock("~/state/environments", async () => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + discovery.listeners.add(listener); + return () => discovery.listeners.delete(listener); + }; + const read = () => { + if (discovery.state === null) throw new Error("Discovery fixture is not initialized"); + return discovery.state; + }; + return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) }; +}); +vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null })); +vi.mock("../ui/button", () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; + +const newMachineId = EnvironmentId.make("new-computer"); +const linkedMachines: DiscoveredEnvironments = new Map([ + [ + newMachineId, + { + environment: { + environmentId: newMachineId, + label: "Work laptop", + endpoint: { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test/ws", + providerKind: "manual", + }, + linkedAt: "2026-09-05T12:00:00.000Z", + }, + availability: "online", + status: Option.none(), + error: Option.none(), + }, + ], +]); + +let renderer: ReactTestRenderer | null; +let page: EventTarget & { visibilityState: DocumentVisibilityState }; +let browserWindow: EventTarget; + +function publish(state: Discovery.RelayEnvironmentDiscoveryState) { + discovery.state = state; + for (const listener of discovery.listeners) listener(); +} + +async function mount(refreshWhileEmpty = true) { + await act(async () => { + renderer = create( + Waiting for your computer to connect.

} + />, + ); + }); +} + +async function advance(milliseconds: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + page = Object.assign(new EventTarget(), { visibilityState: "visible" as const }); + browserWindow = new EventTarget(); + vi.stubGlobal("document", page); + vi.stubGlobal("window", browserWindow); + renderer = null; + discovery.listeners.clear(); + discovery.state = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), + }; + discovery.listEnvironments.mockReset().mockResolvedValue(new Map()); + discovery.refresh.mockReset().mockImplementation(async () => { + publish({ environments: new Map(), refreshing: true, offline: false, error: Option.none() }); + const environments = await discovery.listEnvironments(); + publish({ environments, refreshing: false, offline: false, error: Option.none() }); + return AsyncResult.success(undefined); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("cloud onboarding discovery", () => { + it("shows a newly linked computer without remounting and stops polling once found", async () => { + discovery.listEnvironments + .mockResolvedValueOnce(new Map()) + .mockResolvedValueOnce(linkedMachines); + await mount(); + expect(renderer!.root.findByType("p").children).toEqual([ + "Waiting for your computer to connect.", + ]); + + await advance(5_000); + + expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([ + "Work laptop", + ]); + expect(renderer!.root.findByType("button").children).toEqual(["Connect"]); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("waits while hidden and refreshes immediately when visible again", async () => { + page.visibilityState = "hidden"; + await mount(); + await advance(30_000); + expect(discovery.listEnvironments).not.toHaveBeenCalled(); + + await act(async () => { + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + }); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + browserWindow.dispatchEvent(new Event("focus")); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + }); + + it("does not overlap a slow refresh or restart polling after unmount", async () => { + let resolveRefresh!: (environments: DiscoveredEnvironments) => void; + const pending = new Promise((resolve) => { + resolveRefresh = resolve; + }); + discovery.listEnvironments.mockResolvedValueOnce(new Map()).mockReturnValueOnce(pending); + await mount(); + await advance(5_000); + expect(renderer!.root.findByType("p").children).toEqual([ + "Waiting for your computer to connect.", + ]); + + browserWindow.dispatchEvent(new Event("focus")); + page.dispatchEvent(new Event("visibilitychange")); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + + await act(async () => renderer!.unmount()); + renderer = null; + await act(async () => resolveRefresh(new Map())); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("pauses while offline and resumes when discovery is online", async () => { + await mount(); + await act(async () => publish({ ...discovery.state!, offline: true })); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + + await act(async () => publish({ ...discovery.state!, offline: false })); + await advance(5_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(2); + }); + + it("does not add polling to other cloud lists", async () => { + await mount(false); + await advance(30_000); + expect(discovery.listEnvironments).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 460a253812a0..7f29c69c3208 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -11,7 +11,7 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useEffectEvent, useState } from "react"; import { environmentCatalog } from "~/connection/catalog"; import { cn } from "~/lib/utils"; @@ -25,6 +25,8 @@ import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; +const EMPTY_DISCOVERY_REFRESH_INTERVAL_MS = 5_000; + export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; @@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, savedEnvironments, showSavedEnvironments = false, + refreshWhileEmpty = false, empty = null, }: { readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironments: ReadonlyArray; readonly showSavedEnvironments?: boolean; + readonly refreshWhileEmpty?: boolean; readonly empty?: ReactNode; }) { const environmentsState = useRelayEnvironmentDiscovery(); @@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({ const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { reportFailure: false, }); + const refreshDiscoveryWhenIdle = useEffectEvent(async () => { + if (environmentsState.refreshing || environmentsState.offline) return; + await refreshRelayEnvironments(); + }); const connectRelayEnvironment = useCallback( (environment: RelayClientEnvironmentRecord) => registerEnvironment( @@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({ ); useEffect(() => { - void refreshRelayEnvironments(); - }, [refreshRelayEnvironments]); + if (!refreshWhileEmpty || document.visibilityState === "visible") { + void refreshRelayEnvironments(); + } + }, [refreshRelayEnvironments, refreshWhileEmpty]); const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); @@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({ environment.environmentId !== primaryEnvironmentId && (showSavedEnvironments || !savedById.has(environment.environmentId)), ); + // Discovery clears its list on refresh, so poll only until a machine appears. + const shouldRefreshWhileEmpty = + refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline; + + useEffect(() => { + if (!shouldRefreshWhileEmpty) return; + let timer: ReturnType | undefined; + let disposed = false; + let pending = false; + const visible = () => document.visibilityState === "visible"; + const schedule = () => { + clearTimeout(timer); + if (!disposed && visible()) { + timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS); + } + }; + const refresh = async () => { + if (disposed || pending || !visible()) return; + clearTimeout(timer); + pending = true; + try { + await refreshDiscoveryWhenIdle(); + } finally { + pending = false; + schedule(); + } + }; + const onFocus = () => void refresh(); + const onVisibilityChange = () => { + clearTimeout(timer); + if (visible()) void refresh(); + }; + + schedule(); + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + disposed = true; + clearTimeout(timer); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [shouldRefreshWhileEmpty]); const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( + !refreshWhileEmpty && standalone && visibleEnvironments.length === 0 && environmentsState.refreshing && diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx new file mode 100644 index 000000000000..a9df5cb00b59 --- /dev/null +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -0,0 +1,244 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { Atom } from "effect/unstable/reactivity"; +import { RotateCcwIcon } from "lucide-react"; +import { useEffect, useLayoutEffect, useState } from "react"; + +import { + ensureClientSettingsHydrated, + useClientSettings, + useClientSettingsHydrationStatus, +} from "../../hooks/useSettings"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, + type FirstRunGateState, +} from "../../onboarding/firstRun.logic"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { environmentProjects } from "../../state/projects"; +import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server"; +import { environmentShell } from "../../state/shell"; +import { environmentThreadShells } from "../../state/threads"; +import { Button } from "../ui/button"; + +/** + * Holds back authenticated and hosted app trees until the first-run decision + * is known, so a fresh install never flashes the main screen before the wizard. + * Nothing renders while pending — no shell, no EventRouter (whose welcome + * payload would otherwise navigate into a thread), no dialogs. + * + * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as + * settings hydrate (the common case, no server round-trip). A `null` flag also + * covers installs that predate the field, so it alone is not enough — the gate + * waits for environment shells to bootstrap and inspects the workspace. + * Hosted mode instead checks its saved environment catalog. A timeout shows + * recovery for an unreachable primary server without mounting the app tree. + */ + +const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; + +const primaryShellLiveAtom = Atom.make((get) => { + const serverConfig = get(primaryServerConfigAtom); + return ( + serverConfig !== null && + get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live" + ); +}).pipe(Atom.withLabel("web-onboarding-primary-shell-live")); + +const workspaceEvidenceLiveAtom = Atom.make((get) => { + const environmentIds = new Set([ + ...get(environmentProjects.projectsAtom).map((project) => project.environmentId), + ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId), + ]); + + for (const environmentId of environmentIds) { + if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") { + return false; + } + } + + return true; +}).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live")); + +export function FirstRunGate({ + enabled, + hostedStatic, + children, +}: { + readonly enabled: boolean; + readonly hostedStatic: boolean; + readonly children: React.ReactNode; +}) { + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const hydrationStatus = useClientSettingsHydrationStatus(); + const hydrated = hydrationStatus === "ready"; + const completeOnboarding = useCompleteOnboarding(); + const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const projects = useProjects(); + const threads = useThreadShells(); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); + const primaryShellLive = useAtomValue(primaryShellLiveAtom); + const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom); + // Within a session settings stay hydrated, so remounts (e.g. returning from + // the wizard) resolve synchronously instead of blanking a frame. + const [gateState, setGateState] = useState(() => ({ + decision: + (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null) + ? "app" + : "pending", + stalled: false, + })); + const { decision, stalled } = gateState; + const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying"; + const ownsOnboardingTheme = settingsReadFailed || stalled || decision === "wizard"; + + useLayoutEffect(() => { + if (!ownsOnboardingTheme) return; + return mountOnboardingTheme(); + }, [ownsOnboardingTheme]); + + // A workspace still counts as fresh when its only content is the server's + // own cwd auto-bootstrap: web mode creates a project + thread from cwd at + // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no + // projects at all" would mean `npx t3` users never see the wizard. Any + // other project, more than one thread, or state in a non-primary + // environment is real user state — the aggregate hooks span every + // environment, and a saved remote's project must never read as "the + // bootstrap project" just because its root string matches the primary cwd. + const serverCwd = serverConfig?.cwd ?? null; + const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null; + const workspaceFresh = isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd, + bootstrapProjectId: serverWelcome?.bootstrapProjectId, + bootstrapThreadId: serverWelcome?.bootstrapThreadId, + bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated, + bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated, + projects, + threads, + }); + + const { decision: nextDecision, persistCompletion } = hostedStatic + ? resolveHostedFirstRunDecision({ + hydrated, + completed: onboardingCompletedAt !== null, + catalogReady: environmentCatalogReady, + environmentCount: environments.length, + }) + : resolveFirstRunDecision({ + enabled, + hydrated, + completed: onboardingCompletedAt !== null, + bootstrapped, + authoritative: primaryShellLive, + workspaceAuthoritative: workspaceEvidenceLive, + workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: serverWelcome !== null, + bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, + }), + catalogReady: environmentCatalogReady, + serverConfigAvailable: serverConfig !== null, + workspaceFresh, + projectCount: projects.length, + threadCount: threads.length, + }); + + useEffect(() => { + if (decision === "wizard" || !hydrated) return; + + if (persistCompletion && onboardingCompletedAt === null) { + void completeOnboarding().catch(() => undefined); + } + + setGateState((state) => + transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }), + ); + }, [ + completeOnboarding, + decision, + hydrated, + nextDecision, + onboardingCompletedAt, + persistCompletion, + ]); + + // A stalled server read gets a recovery screen, but never mounts the app. + // The timer starts after settings hydrate so slow local hydration does not + // show a false connection failure. + useEffect(() => { + if (!enabled || decision !== "pending" || !hydrated) return; + const timer = window.setTimeout( + () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })), + FIRST_RUN_DECISION_TIMEOUT_MS, + ); + return () => window.clearTimeout(timer); + }, [decision, enabled, hydrated]); + + useEffect(() => { + if (decision === "wizard" && pathname !== "/welcome") { + void navigate({ to: "/welcome", replace: true }); + } + }, [decision, navigate, pathname]); + + if (settingsReadFailed) { + return ; + } + if (decision !== "app") { + return stalled ? : null; + } + return children; +} + +function FirstRunRecovery({ + reason, + retrying = false, +}: { + readonly reason: "settings" | "connection"; + readonly retrying?: boolean; +}) { + const settingsReadFailed = reason === "settings"; + return ( +
+
+

+ {settingsReadFailed ? "Could not read settings" : "Still connecting"} +

+

+ {settingsReadFailed + ? "Your saved settings could not be loaded." + : "T3 Code could not confirm this workspace."} +

+ +
+
+ ); +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx new file mode 100644 index 000000000000..cb31ef259667 --- /dev/null +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -0,0 +1,1478 @@ +import { useAuth } from "@clerk/react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentSessionProjectCandidate, + EnvironmentId, + ProjectId, + ScopedProjectRef, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + ArrowRightIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CloudIcon, + CopyIcon, + LinkIcon, + MonitorIcon, + TerminalIcon, + type LucideIcon, +} from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "../../onboarding/projectImport.logic"; +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "../../onboarding/providerReadiness.logic"; +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "../../onboarding/targetEnvironment.logic"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { newProjectId, randomUUID } from "../../lib/utils"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; +import { readProjects, useProjects } from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { projectEnvironment } from "../../state/projects"; +import { serverEnvironment } from "../../state/server"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { connectPairing } from "../../connection/onboarding"; +import { isElectron } from "../../env"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { getProviderSummary } from "../settings/providerStatus"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { TerminalViewport } from "../ThreadTerminalDrawer"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { cn } from "../../lib/utils"; + +/** + * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a + * fresh install (no completed-onboarding flag, empty workspace). Flow per the + * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → + * agent setup with inline install terminal → project import → main screen. + * Every step past the connection gate is skippable; the whole wizard is + * re-runnable by clearing the flag. + */ + +type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import"; + +type ConnectionMode = "local" | "connect" | "direct"; + +/** + * The machine the agent and import steps run against. Local mode targets the + * primary environment; the remote modes prefer the machine the user just + * connected (the most recently added connected non-primary environment), so + * probing and import happen where their code lives rather than on the local + * server that happens to serve the app. Deliberately not a persisted + * "primary machine" concept — just whichever machine fits the chosen path + * right now, labeled inline on each step. + */ +function useOnboardingTargetEnvironment( + mode: ConnectionMode, + pairedEnvironmentId: EnvironmentId | null, +) { + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + return resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, + }); +} + +const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); +const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const; +const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing."; + +export function WelcomeWizard({ + localAvailable, + onDone, +}: { + /** + * Whether the "Local Only" card is offered. True whenever the app is served + * by an authenticated primary server — desktop, `npx t3`, or a dev server — + * since that server is "this machine" regardless of the hostname the app + * was opened from. Only hosted-static (app.t3.codes) has no local server. + */ + readonly localAvailable: boolean; + readonly onDone: (projectRef?: ScopedProjectRef) => void; +}) { + useLayoutEffect(() => mountOnboardingTheme(), []); + const completeOnboarding = useCompleteOnboarding(); + const [step, setStep] = useState("connection"); + const [mode, setMode] = useState("local"); + const [pairedEnvironmentId, setPairedEnvironmentId] = useState(null); + const finishingPromiseRef = useRef | null>(null); + const completionErrorToastIdRef = useRef | null>(null); + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; + const finish = useCallback( + (projectRef?: ScopedProjectRef) => { + if (finishingPromiseRef.current !== null) return finishingPromiseRef.current; + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + + const completion = completeOnboarding() + .then(() => { + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + onDone(projectRef); + return true; + }) + .catch(() => { + const errorToast = { + type: "error", + title: "Could not finish setup", + description: "Your settings could not be saved. Try again.", + } as const; + if (completionErrorToastIdRef.current === null) { + completionErrorToastIdRef.current = toastManager.add(errorToast); + } else { + toastManager.update(completionErrorToastIdRef.current, errorToast); + } + return false; + }) + .finally(() => { + if (finishingPromiseRef.current === completion) { + finishingPromiseRef.current = null; + } + }); + finishingPromiseRef.current = completion; + return completion; + }, + [completeOnboarding, onDone], + ); + + return ( +
+ {isElectron ? ( +
+ ) : null} +
+
+ + +
+ {step === "connection" ? ( + { + setMode("local"); + setPairedEnvironmentId(null); + setStep("agents"); + }} + onConnect={() => { + setMode("connect"); + setPairedEnvironmentId(null); + setStep("connect-machines"); + }} + onDirect={() => { + setMode("direct"); + setPairedEnvironmentId(null); + setStep("pair-direct"); + }} + /> + ) : step === "connect-machines" ? ( + setStep("connection")} + onContinue={() => setStep("agents")} + /> + ) : step === "pair-direct" ? ( + setStep("connection")} + onPaired={(environmentId) => { + setPairedEnvironmentId(environmentId); + setStep("agents"); + }} + /> + ) : step === "agents" ? ( + + setStep( + mode === "local" + ? "connection" + : mode === "connect" + ? "connect-machines" + : "pair-direct", + ) + } + onContinue={() => setStep("import")} + onSkip={() => setStep("import")} + /> + ) : ( + setStep("agents")} + onDone={finish} + /> + )} +
+
+
+
+ ); +} + +// ── Step 1: connection choice ──────────────────────────────── + +function ConnectionStep({ + localAvailable, + localLabel, + onLocal, + onConnect, + onDirect, +}: { + readonly localAvailable: boolean; + readonly localLabel: string; + readonly onLocal: () => void; + readonly onConnect: () => void; + readonly onDirect: () => void; +}) { + const cloudEnabled = hasCloudPublicConfig(); + const [choice, setChoice] = useState<"local" | "connect" | "direct">( + localAvailable ? "local" : cloudEnabled ? "connect" : "direct", + ); + + const advance = () => { + if (choice === "local") onLocal(); + else if (choice === "connect") onConnect(); + else onDirect(); + }; + + return ( + <> +

Where is your code?

+

Choose where your agents will run.

+
+ {localAvailable ? ( + setChoice("local")} + /> + ) : null} + {cloudEnabled ? ( + setChoice("connect")} + /> + ) : null} + setChoice("direct")} + /> +
+
+ +
+ + ); +} + +function ConnectionOption({ + icon: Icon, + title, + description, + truncateDescription = false, + detail, + selected, + onSelect, +}: { + readonly icon: LucideIcon; + readonly title: string; + readonly description: string; + readonly truncateDescription?: boolean; + readonly detail: string; + readonly selected: boolean; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +// ── Step 2: T3 Connect (sign in, then connect machines) ────── + +const CONNECT_LOGIN_COMMAND = "npx t3 connect"; + +/** + * Sign-in and machine-connection combined: signed out shows the Clerk prompt, + * signed in forks on account state — zero connected machines blocks on the + * `npx t3 connect` command and auto-advance is left to the user pressing + * Continue once their machine appears; existing machines show a confirmation + * list with the command folded away. There is deliberately no "primary + * machine" selection. + */ +function ConnectMachinesStep({ + onBack, + onContinue, +}: { + readonly onBack: () => void; + readonly onContinue: () => void; +}) { + // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read + // as signed-out mid-transition. + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const savedEnvironments = environments.filter(isOnboardingRelayEnvironment); + // Only a live connection counts: a saved-but-offline machine must not show + // the "connected" confirmation (the agents step would find nothing to + // probe). Its row still renders in the list either way. + const hasRemoteMachines = savedEnvironments.some( + (environment) => environment.connection.phase === "connected", + ); + + if (!isLoaded) { + return ; + } + + if (!isSignedIn) { + return ( + +
+ +
+
+ ); + } + + return ( + + {hasRemoteMachines ? ( + <> +
+ +
+ + + + Add another machine + + + +

+ Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

+
+
+
+ +
+ + ) : ( + <> + +

+ Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

+
+ + Waiting for your computer to connect. +

+ } + /> +
+
+ +
+ + Waiting for connection + + +
+
+ + )} +
+ ); +} + +// ── Step 2′: Direct pairing ────────────────────────────────── + +/** + * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the + * server, paste the URL here. Registers the remote environment in this + * browser's catalog (same path the hosted /pair surface uses). + */ +function PairDirectStep({ + onBack, + onPaired, +}: { + readonly onBack: () => void; + readonly onPaired: (environmentId: EnvironmentId) => void; +}) { + const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false }); + const [pairingUrl, setPairingUrl] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isPairing, setIsPairing] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const submit = async () => { + setIsPairing(true); + setErrorMessage(""); + const result = await connectPairingEnvironment({ pairingUrl }); + if (!mountedRef.current) return; + setIsPairing(false); + if (result._tag === "Success") { + onPaired(result.value); + return; + } + if (isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed."); + }; + + return ( + +
+
+

+ 01 Run this on your server +

+ +

+ Start the server with npx t3 serve first. Add{" "} + --tailscale to use your tailnet. +

+
+
+ + setPairingUrl(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit(); + }} + /> +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ +
+
+ ); +} + +// ── Step 3: agents ─────────────────────────────────────────── + +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; + +const AGENT_INSTALL_COMMANDS: Record = { + claudeAgent: "npm install -g @anthropic-ai/claude-code", + codex: "npm install -g @openai/codex", +}; + +/** Setup values stay fixed while provider probes refresh the surrounding cards. */ +interface AgentTerminalSession { + readonly environmentId: EnvironmentId; + readonly driver: OnboardingAgentDriver; + readonly providerInstanceId: ServerProvider["instanceId"]; + readonly cwd: string; + readonly command: string; + readonly keybindings: ServerConfig["keybindings"]; +} + +/** + * Claude Code and Codex use live probe status. Install opens the built-in terminal inline + * with the command pre-typed — the update RPC can't install a binary that + * isn't there yet (it infers the package manager from the installed binary's + * path), and the terminal also handles the interactive login that follows. + */ +function AgentsStep({ + mode, + pairedEnvironmentId, + onBack, + onContinue, + onSkip, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + if (targetEnvironment === null) { + return ( + +
+ +
+
+ ); + } + return ( + + ); +} + +function ConnectedAgentsStep({ + environmentId, + machineLabel, + onBack, + onContinue, + onSkip, +}: { + readonly environmentId: EnvironmentId; + readonly machineLabel: string; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const [terminalSession, setTerminalSession] = useState(null); + + // Re-probe on entry so freshly installed CLIs show up without a manual + // refresh; harmless when nothing changed (single-flighted per environment). + useEffect(() => { + void refreshProviders({ environmentId, input: {} }); + }, [environmentId, refreshProviders]); + + const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); + + const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ + driver, + provider: byDriver.get(driver), + })); + const readyCount = primaryAgents.filter( + ({ provider }) => getOnboardingProviderState(provider) === "ready", + ).length; + return ( + +
+ {primaryAgents.map(({ driver, provider }) => ( + { + if (provider === undefined || serverConfig === null) return; + setTerminalSession({ + environmentId, + driver, + providerInstanceId: provider.instanceId, + cwd: serverConfig.cwd, + command: provider.installed + ? resolveOnboardingProviderLoginCommand( + provider, + serverConfig.settings, + serverConfig.environment.platform.os, + ) + : AGENT_INSTALL_COMMANDS[driver], + keybindings: serverConfig.keybindings, + }); + }} + /> + ))} +
+ {terminalSession !== null ? ( + { + setTerminalSession(null); + void refreshProviders({ environmentId, input: {} }); + }} + /> + ) : null} +
+ +
+ + {readyCount} of {primaryAgents.length} ready + + +
+
+
+ ); +} + +function AgentCard({ + driver, + provider, + terminalOpen, + terminalAvailable, + onOpenTerminal, +}: { + readonly driver: OnboardingAgentDriver; + readonly provider: ServerProvider | undefined; + readonly terminalOpen: boolean; + readonly terminalAvailable: boolean; + readonly onOpenTerminal: () => void; +}) { + const meta = getDriverOption(ProviderDriverKind.make(driver)); + const Icon = meta?.icon; + const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); + const summary = getProviderSummary(provider); + const providerState = getOnboardingProviderState(provider); + + return ( +
+ {Icon ? ( + + ) : null} +
+ {displayName} +

+ {summary.headline} + {summary.detail ? ` · ${summary.detail}` : ""} +

+
+
+ {providerState === "ready" ? ( + + + Ready + + ) : providerState === "checking" ? ( + Checking... + ) : providerState === "disabled" ? ( + Disabled + ) : providerState === "attention" ? ( + {summary.headline} + ) : ( + + )} +
+
+ ); +} + +/** + * Inline install terminal. Opens a PTY on the connected environment under a + * synthetic onboarding thread id (terminals are keyed by free-form thread id; + * the server validates only the cwd) and pre-types the install or login + * command without submitting, so the user reviews and presses Enter. + */ +function AgentInstallTerminal({ + session, + onClose, +}: { + readonly session: AgentTerminalSession; + readonly onClose: () => void; +}) { + const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session; + // Same terminal typography preference the thread drawer honors. + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false }); + const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false }); + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const setupQueueRef = useRef(Promise.resolve()); + const setupGenerationRef = useRef(0); + const activeSetupGenerationRef = useRef(null); + const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID), + [environmentId], + ); + const [setupAttempt, setSetupAttempt] = useState(0); + const [setupState, setSetupState] = useState< + "preparing" | "ready" | "openFailed" | "writeFailed" + >("preparing"); + const terminalReady = setupState === "ready" || setupState === "writeFailed"; + + // Keep each setup generation distinct. In Strict Mode, a canceled open can + // finish after the replacement setup starts; it must not close or pre-type + // into the replacement session that shares this terminal id. + useEffect(() => { + const generation = setupGenerationRef.current + 1; + setupGenerationRef.current = generation; + activeSetupGenerationRef.current = generation; + setSetupState("preparing"); + + setupQueueRef.current = setupQueueRef.current.then(async () => { + if (activeSetupGenerationRef.current !== generation) return; + const opened = await openTerminal({ + environmentId, + input: { + threadId: AGENT_ONBOARDING_THREAD_ID, + terminalId, + cwd, + providerInstanceId, + }, + }); + if (opened._tag !== "Success") { + if (activeSetupGenerationRef.current === generation) setSetupState("openFailed"); + return; + } + + if (activeSetupGenerationRef.current !== generation) return; + + const wrote = await writeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command }, + }); + if (activeSetupGenerationRef.current !== generation) return; + setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed"); + }); + + // Every exit path unmounts the drawer (Done, Continue/Skip, card switch, + // session exit), so this cleanup is the single place the PTY dies — + // nothing is left running behind the wizard. An interrupted install is + // re-runnable from the card. + return () => { + if (activeSetupGenerationRef.current === generation) { + activeSetupGenerationRef.current = null; + } + setupQueueRef.current = setupQueueRef.current.then(async () => { + await closeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true }, + }); + }); + }; + }, [ + closeTerminal, + command, + cwd, + environmentId, + openTerminal, + providerInstanceId, + setupAttempt, + terminalId, + writeTerminal, + ]); + + return ( +
+
+ + {setupState === "writeFailed" ? ( + <> + Run {command} in this + terminal. + + ) : setupState === "ready" ? ( + "Review the command, then press Enter to run it." + ) : setupState === "openFailed" ? ( + "Could not open the setup terminal." + ) : ( + "Preparing command..." + )} + +
+ {setupState === "openFailed" ? ( + + ) : null} + +
+
+
+ {terminalReady ? ( + + ) : null} +
+
+ ); +} + +// ── Step 4: import ─────────────────────────────────────────── + +/** + * One-decision import (4B): a summary line with Import recent / Choose / + * Skip. The default imports only projects touched in the last 30 days; + * Choose expands a checklist including older ones. Imported projects also + * receive Codex and Claude threads active within the last 30 days. + */ +function ImportStep({ + mode, + pairedEnvironmentId, + onBack, + onDone, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onDone: (projectRef?: ScopedProjectRef) => Promise; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const environmentId = targetEnvironment?.environmentId ?? null; + const machineLabel = targetEnvironment?.label ?? "this machine"; + const providers = useAtomValue( + serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)), + ); + const scan = useEnvironmentQuery( + environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), + ); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); + const projects = useProjects(); + const [choosing, setChoosing] = useState(false); + const [deselected, setDeselected] = useState>(new Set()); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(""); + const [landingProject, setLandingProject] = useState(null); + // Keep project creation attempts separate from completed history imports so both can retry. + const importedProjectsRef = useRef(new Map()); + const projectsWithImportedHistoryRef = useRef(new Map()); + const lastImportSelectionRef = useRef>([]); + const projectAttemptsRef = useRef( + new Map(), + ); + const importGenerationRef = useRef(0); + + // Candidate paths are per-environment; a target switch would otherwise + // leave stale entries in the deselection set (and stale success records). + useEffect(() => { + importGenerationRef.current += 1; + setDeselected(new Set()); + setIsImporting(false); + setImportError(""); + setLandingProject(null); + importedProjectsRef.current = new Map(); + projectsWithImportedHistoryRef.current = new Map(); + lastImportSelectionRef.current = []; + projectAttemptsRef.current = new Map(); + return () => { + importGenerationRef.current += 1; + }; + }, [environmentId]); + + useEffect(() => { + if ( + landingProject !== null && + landingProject.environmentId === environmentId && + projects.some( + (project) => + project.id === landingProject.projectId && + project.environmentId === landingProject.environmentId, + ) + ) { + setLandingProject(null); + void onDone(landingProject).then((completed) => { + if (!completed) setIsImporting(false); + }); + } + }, [environmentId, landingProject, onDone, projects]); + + const { available: candidates, recent } = useMemo( + () => partitionOnboardingProjects(scan.data?.candidates ?? []), + [scan.data], + ); + const more = candidates.length - recent.length; + const scanTruncated = scan.data?.truncated === true; + const scanLimitNotice = scanTruncated ? ( +

+ {SCAN_LIMIT_MESSAGE} +

+ ) : null; + + const finishAfterImport = () => { + const projectRef = resolveOnboardingLandingProject( + lastImportSelectionRef.current, + projectsWithImportedHistoryRef.current, + importedProjectsRef.current, + ); + if (projectRef === undefined) { + void onDone(); + return; + } + setIsImporting(true); + setLandingProject(projectRef); + }; + + const runImport = async (selection: ReadonlyArray) => { + if (environmentId === null || selection.length === 0) { + void onDone(); + return; + } + setIsImporting(true); + setImportError(""); + lastImportSelectionRef.current = selection.map((candidate) => candidate.path); + const importGeneration = importGenerationRef.current; + const importedProjects = importedProjectsRef.current; + const projectAttempts = projectAttemptsRef.current; + const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null); + // Interrupted imports are neither failures nor successes — the command was + // superseded or the environment dropped — but they still didn't land, so + // they must not read as "imported everything". Retries skip paths that + // already landed this session (re-creating them would only trip the + // duplicate-root invariant and read as a failure). + let importedProjectsCount = + importedProjects.size > 0 + ? selection.filter((candidate) => importedProjects.has(candidate.path)).length + : 0; + let importedThreadCount = 0; + let skippedThreadCount = 0; + let shouldRefreshScan = false; + for (const candidate of selection) { + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (importedProjects.has(candidate.path)) continue; + let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate); + if (projectId === null) { + let attempt = projectAttempts.get(candidate.path); + if (attempt === undefined) { + const nextProjectId = newProjectId(); + attempt = { + projectId: nextProjectId, + commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`), + }; + projectAttempts.set(candidate.path, attempt); + } + projectId = attempt.projectId; + const result = await createProject({ + environmentId, + input: { + projectId, + commandId: attempt.commandId, + title: candidate.title, + workspaceRoot: candidate.path, + createWorkspaceRootIfMissing: false, + defaultModelSelection, + }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (result._tag !== "Success") { + if (!isAtomCommandInterrupted(result)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + continue; + } + } + + const threadImportResult = await importThreads({ + environmentId, + input: { projectId, expectedWorkspaceRoot: candidate.path }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (threadImportResult._tag === "Success") { + importedThreadCount += threadImportResult.value.importedCount; + skippedThreadCount += threadImportResult.value.skippedCount; + if (threadImportResult.value.importedCount > 0) { + projectsWithImportedHistoryRef.current.set( + candidate.path, + scopeProjectRef(environmentId, projectId), + ); + } + if (threadImportResult.value.skippedCount === 0) { + importedProjectsCount += 1; + importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId)); + } + } else if (!isAtomCommandInterrupted(threadImportResult)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + } + if (shouldRefreshScan) scan.refresh(); + setIsImporting(false); + if (importedProjectsCount < selection.length) { + if (importedThreadCount > 0 && skippedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`, + ); + } else if (skippedThreadCount > 0) { + setImportError( + `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`, + ); + } else if (importedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`, + ); + } else { + setImportError("Could not import thread history."); + } + return; + } + finishAfterImport(); + }; + + if (environmentId === null || (scan.isPending && scan.data === null)) { + return ( + +
+ +
+
+ ); + } + + if (scan.error !== null || candidates.length === 0) { + return ( + + {scan.error !== null ? ( +

You can add projects later.

+ ) : null} +
+ {scan.error !== null ? ( + + ) : null} + +
+
+ ); + } + + if (choosing) { + const selected = candidates.filter((candidate) => !deselected.has(candidate.path)); + return ( + setChoosing(false)} + backDisabled={isImporting} + description={`${candidates.length} found on ${machineLabel}.`} + > + {scanLimitNotice} +
+ {candidates.map((candidate) => ( + + ))} +
+ {importError ?

{importError}

: null} +
+ + +
+
+ ); + } + + return ( + 0 ? ` ${more} more available.` : ""}`} + onBack={onBack} + backDisabled={isImporting} + > + {scanLimitNotice} +
+ {recent.slice(0, 4).map((candidate) => ( +
+ + + {candidate.path} + + + {candidate.sources.map(formatSource).join(", ")} + +
+ ))} + {recent.length > 4 ? ( +

+ {recent.length - 4} more projects +

+ ) : null} +
+ {importError ?

{importError}

: null} +
+ +
+ + +
+
+
+ ); +} + +// ── Shared bits ────────────────────────────────────────────── + +function StepShell({ + title, + description, + onBack, + backDisabled = false, + children, +}: { + readonly title: string; + readonly description?: string; + readonly onBack?: () => void; + readonly backDisabled?: boolean; + readonly children?: React.ReactNode; +}) { + return ( + <> + {onBack ? ( + + ) : null} +

{title}

+ {description ? ( +

{description}

+ ) : null} + {children} + + ); +} + +function CommandBlock({ + command, + className, + prominent = false, +}: { + readonly command: string; + readonly className?: string; + readonly prominent?: boolean; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1500, + target: "command", + }); + return ( +
+ + $ + {command} + + +
+ ); +} + +function formatSource(source: "claudeAgent" | "codex"): string { + return source === "claudeAgent" ? "Claude" : "Codex"; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx new file mode 100644 index 000000000000..75ed20cc4fe7 --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -0,0 +1,190 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ThreadId, + type ClientSettings, + type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, + type PreviewOpenInput, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; + +import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn(), + open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ), + list: vi.fn(async () => AsyncResult.success(emptyList)), + resize: vi.fn(), + respond: + vi.fn< + (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise + >(), + focus: vi.fn(async () => undefined), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId }] }), +})); +vi.mock("~/state/preview", () => ({ + previewEnvironment: { + automationRequests: () => requestsAtom, + list: () => listAtom, + open: mocks.open, + resize: mocks.resize, + respondToAutomation: mocks.respond, + focusAutomationHost: mocks.focus, + }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => mocks.list, +})); +vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); + +const environmentId = EnvironmentId.make("automation-environment"); +const threadId = ThreadId.make("automation-thread"); +const threadRef = { environmentId, threadId }; +const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; +const savedSettings: ClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], +}; +const snapshot: PreviewSessionSnapshot = { + threadId, + tabId: "automation-tab", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport, + profileId: "work", + updatedAt: "2026-09-05T00:00:00.000Z", +}; +const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; +const listAtom = Atom.make(AsyncResult.success(emptyList)); +const requestsAtom = Atom.make>( + AsyncResult.initial(false), +); +const requestEvent: PreviewAutomationStreamEvent = { + type: "request", + connectionId: "automation-connection", + request: { + requestId: "open-request", + threadId, + operation: "open", + input: { open: false, reuseExistingTab: false }, + timeoutMs: 15_000, + }, +}; + +function deferred
() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +let renderer: ReactTestRenderer | null = null; + +beforeEach(async () => { + vi.clearAllMocks(); + mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); + mocks.respond.mockReset(); + __resetClientSettingsPersistenceForTests(); + resetPreviewStateForTests(); + appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); + await act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("PreviewAutomationHosts open", () => { + it("waits for saved settings before opening a tab with the configured profile and viewport", async () => { + const readStarted = deferred(); + const read = deferred(); + const response = deferred(); + mocks.getClientSettings.mockImplementationOnce(() => { + readStarted.resolve(); + return read.promise; + }); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await readStarted.promise; + }); + expect(mocks.open).not.toHaveBeenCalled(); + + await act(async () => { + read.resolve(savedSettings); + await response.promise; + }); + + expect(mocks.open).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { threadId, viewport, profileId: "work" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); + + it("reports a settings read failure without opening a tab", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed")); + const response = deferred(); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await response.promise; + }); + + await expect(response.promise).resolves.toMatchObject({ + requestId: "open-request", + ok: false, + error: { _tag: "PreviewAutomationExecutionError" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 08b31640906e..fd87f7e80c79 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -41,7 +41,11 @@ import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, } from "~/browser/browserSurfaceStore"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; @@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const defaults = await resolveBrowserDefaults(); const result = await open({ environmentId, input: { @@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), // An agent that didn't state a size gets the user's // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 7e0bf2dfb543..e6ad2758bc48 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,7 +1,10 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, @@ -46,6 +49,7 @@ import { } from "~/browser/browserViewportActions"; import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -186,6 +190,16 @@ export function PreviewView({ return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add({ + type: "error", + title: "Unable to open browser", + description: error.message, + }); + } + } return result._tag === "Success"; }, [open, runtimeTabId, threadRef], diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index f26cb0fff9e1..d34de83a23b4 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -14,6 +15,7 @@ import { resetPreviewStateForTests, } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { addBrowserSurface } from "./addBrowserSurface"; @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ }); beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); resetPreviewStateForTests(); useRightPanelStore.setState({ byThreadKey: {} }); }); diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 622cdbec2f1c..e0cd83501201 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -4,7 +4,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -15,7 +15,7 @@ export async function addBrowserSurface(input: { readonly openPreview: OpenPreviewMutation; /** Omit to use the configured default profile. */ readonly profileId?: string | undefined; -}): Promise> { +}): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index a49acbd86104..288db101e7a5 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,7 +14,7 @@ export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise> { const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); const result = await openPreviewSession({ openPreview: input.openPreview, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index ef3d51a9e7fa..fe14211280c2 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -7,8 +8,11 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as browserDefaults from "~/browser/browserDefaults"; +import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("openPreviewSession", () => { it("creates an idle tab without recording a recently visited URL", async () => { @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); }); + + it.each(["session", "link"] as const)( + "does not open a %s with unread settings and uses the saved profile on retry", + async (entryPoint) => { + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const viewport = { _tag: "freeform", width: 1280, height: 720 } as const; + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const input = { openPreview, threadRef, url: "https://t3.chat/" }; + const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview; + + const result = await open(input); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError); + expect(Cause.squash(result.cause)).toMatchObject({ cause: failure }); + } + expect(openPreview).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); + + await expect(open(input)).resolves.toMatchObject({ _tag: "Success" }); + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: input.url, + viewport, + profileId: "work", + }, + }); + }, + ); }); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index deb5465ebc28..07dab9a0b36d 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -6,12 +6,15 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { browserDefaultOpenProfileId, browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput { export async function openPreviewSession( input: OpenPreviewSessionInput, -): Promise> { +): Promise> { // Resolved once: a tab opened before client settings hydrate would otherwise // be born at the schema defaults and never corrected. - const defaults = await resolveBrowserDefaults(); + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 46dd33f7beb4..2ce81cb06af2 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -68,6 +68,33 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it.each(["target", "defaults"] as const)( + "does not open either browser when reading %s fails", + async (setting) => { + const failure = new Error("Settings read failed"); + if (setting === "target") { + linkTargetMocks.preference.mockImplementationOnce(() => { + throw failure; + }); + } else { + browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); + } + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await expect( + openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + }), + ).rejects.toBe(failure); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + expect(openPreview).not.toHaveBeenCalled(); + }, + ); + it("opens in the system browser while that is the configured target", async () => { linkTargetMocks.preference.mockReturnValue("system"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts new file mode 100644 index 000000000000..46dc7e262512 --- /dev/null +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -0,0 +1,71 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderSummary } from "./providerStatus"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", label: "ChatGPT" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getProviderSummary", () => { + it("reports ready providers with unknown authentication as available", () => { + expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({ + headline: "Available", + detail: null, + }); + }); + + it("does not hide a provider error behind a previous authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + message: "The provider process failed to start.", + }), + ).toEqual({ + headline: "Unavailable", + detail: "The provider process failed to start.", + }); + }); + + it("does not hide a provider warning behind an authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "warning", + message: "The provider version is unsupported.", + }), + ).toEqual({ + headline: "Needs attention", + detail: "The provider version is unsupported.", + }); + }); + + it("keeps authentication failures actionable when their provider status is error", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + auth: { status: "unauthenticated" }, + message: "Run codex login.", + }), + ).toEqual({ + headline: "Not authenticated", + detail: "Run codex login.", + }); + }); + + it("treats a disabled provider status as disabled even before its enabled flag updates", () => { + expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 0f39f643f5ce..90c618f5daa7 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; * settings page. Prefers `provider.message` for server-supplied detail and * falls back to generic phrasing when the server has not yet reported any * state — which happens before the first probe or when an instance names a - * driver this build does not ship. + * driver this build does not ship. A ready provider without account metadata + * remains available and does not imply an authentication failure. */ export function getProviderSummary(provider: ServerProvider | undefined) { if (!provider) { @@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: "Waiting for the server to report installation and authentication details.", }; } - if (!provider.enabled) { + if (!provider.enabled || provider.status === "disabled") { return { headline: "Disabled", detail: @@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "CLI not detected on PATH.", }; } - if (provider.auth.status === "authenticated") { - const authLabel = provider.auth.label ?? provider.auth.type; - return { - headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", - detail: provider.message ?? null, - }; - } if (provider.auth.status === "unauthenticated") { return { headline: "Not authenticated", @@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "The provider failed its startup checks.", }; } + if (provider.auth.status === "authenticated") { + const authLabel = provider.auth.label ?? provider.auth.type; + return { + headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", + detail: provider.message ?? null, + }; + } return { headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + detail: provider.message ?? null, }; } diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index fe0345e41524..d76002a93298 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise { } }); - it("preserves decode failure context", async () => { + it("retries when access to browser storage becomes available", async () => { + const storage = createStorage(); + storage.setItem("read-key", JSON.stringify("saved value")); + let blocked = true; + vi.stubGlobal("window", { + get localStorage() { + if (blocked) throw new Error("storage unavailable"); + return storage; + }, + }); + const { getLocalStorageItem, LocalStorageOperationError } = await import("./useLocalStorage"); + + expect(() => getLocalStorageItem("read-key", Schema.String)).toThrow( + LocalStorageOperationError, + ); + blocked = false; + expect(getLocalStorageItem("read-key", Schema.String)).toBe("saved value"); + }); + + it.each(["", "not-json"])("preserves decode failure context for %j", async (value) => { const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( - createStorage({ getItem: () => "not-json" }), + createStorage({ getItem: () => value }), ); try { diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 3099e73ff43f..112715599484 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass(); - return { - clear: () => store.clear(), - getItem: (_) => store.get(_) ?? null, - key: (_) => Record.keys(store).at(_) ?? null, - get length() { - return store.size; - }, - removeItem: (_) => store.delete(_), - setItem: (_, value) => store.set(_, value), - }; - })(); +const fallbackStorage: Storage = (() => { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (_) => store.get(_) ?? null, + key: (_) => Record.keys(store).at(_) ?? null, + get length() { + return store.size; + }, + removeItem: (_) => store.delete(_), + setItem: (_, value) => store.set(_, value), + }; +})(); + +const getStorage = (): Storage => + typeof window !== "undefined" ? window.localStorage : fallbackStorage; const read = (key: string) => { try { - return isomorphicLocalStorage.getItem(key); + return getStorage().getItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); } @@ -58,13 +58,13 @@ const encode = (key: string, schema: Schema.Codec, value: T) => { export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { const item = read(key); - return item ? decode(key, schema, item) : null; + return item === null ? null : decode(key, schema, item); }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { const valueToSet = encode(key, schema, value); try { - isomorphicLocalStorage.setItem(key, valueToSet); + getStorage().setItem(key, valueToSet); } catch (cause) { throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); } @@ -72,7 +72,7 @@ export const setLocalStorageItem = (key: string, value: T, schema: Schema. export const removeLocalStorageItem = (key: string) => { try { - isomorphicLocalStorage.removeItem(key); + getStorage().removeItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 200e14241e13..d55424766883 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -3,12 +3,22 @@ import { ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const persistenceMocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: persistenceMocks }), +})); import { __resetClientSettingsPersistenceForTests, __setClientSettingsForTests, + ensureClientSettingsHydrated, getClientSettings, mergeEnvironmentSettings, persistClientSettingsPatch, @@ -17,9 +27,138 @@ import { } from "./useSettings"; beforeEach(() => { + persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null); + persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined); __resetClientSettingsPersistenceForTests(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("client settings hydration", () => { + const savedSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }], + }; + const onboardingCompletedAt = "2026-09-05T12:00:00.000Z"; + const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt }); + + it("rejects completion after a failed read and preserves saved preferences on retry", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings + .mockRejectedValueOnce(failure) + .mockResolvedValue(savedSettings); + + await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + + const completedSettings = { ...savedSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2); + }); + + it("uses defaults only after storage confirms no saved settings exist", async () => { + const completedSettings = { ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt }; + + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + }); + + it("holds patches until a pending read supplies the saved preferences", async () => { + let finishRead!: (settings: ClientSettings) => void; + persistenceMocks.getClientSettings.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + finishRead(savedSettings); + await hydration; + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + }); + + it("handles failed patch reads without writing and retries with the saved preferences", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings.mockRejectedValue(failure); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + await expect(hydration).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + persistenceMocks.getClientSettings.mockResolvedValue(savedSettings); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + persistClientSettingsPatch({ wordWrap: false }); + + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + }); + + it("preserves patch order across hydration and a blocked completion write", async () => { + let finishRead!: (settings: ClientSettings) => void; + const read = new Promise((resolve) => { + finishRead = resolve; + }); + persistenceMocks.getClientSettings.mockReturnValue(read); + let finishCompletionWrite!: () => void; + const blockedWrite = new Promise((resolve) => { + finishCompletionWrite = resolve; + }); + let signalCompletionWrite!: () => void; + const completionWriteStarted = new Promise((resolve) => { + signalCompletionWrite = resolve; + }); + let durableSettings: ClientSettings = savedSettings; + const persist = vi + .fn<(settings: ClientSettings) => Promise>() + .mockImplementationOnce(async (settings) => { + signalCompletionWrite(); + await blockedWrite; + durableSettings = settings; + }) + .mockImplementation(async (settings) => { + durableSettings = settings; + }); + + const completion = persistClientSettingsUpdate(complete, persist); + persistClientSettingsPatch({ wordWrap: false }, persist); + finishRead(savedSettings); + await completionWriteStarted; + persistClientSettingsPatch({ wordWrap: true }, persist); + const finalWrite = persistClientSettingsUpdate((current) => current, persist); + + finishCompletionWrite(); + await completion; + await finalWrite; + + const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true }; + expect(getClientSettings()).toEqual(expected); + expect(durableSettings).toEqual(expected); + }); +}); + describe("persistClientSettingsUpdate", () => { it("publishes the update only after persistence succeeds", async () => { let finishPersistence!: () => void; @@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => { expect(settings.sidebarAutoSettleOnMerge).toBe(false); }); }); + +describe("onboarding completion persistence", () => { + it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => { + const failure = new Error("disk full"); + const persist = vi + .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise>() + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined); + const existingSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [ + { + provider: ProviderInstanceId.make("codex_work"), + model: "gpt-5.6", + }, + ], + }; + __setClientSettingsForTests(existingSettings); + const onboardingCompletedAt = "2026-09-01T12:00:00.000Z"; + const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({ + ...current, + onboardingCompletedAt, + }); + + await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure); + expect(getClientSettings()).toBe(existingSettings); + expect(getClientSettings().onboardingCompletedAt).toBeNull(); + + const completedSettings = { ...existingSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual( + completedSettings, + ); + expect(getClientSettings()).toEqual(completedSettings); + expect(persist).toHaveBeenLastCalledWith(completedSettings); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 52a6fec12f0f..9b428b08ea50 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -54,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; const clientSettingsListeners = new Set<() => void>(); const clientSettingsHydrationListeners = new Set<() => void>(); +type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying"; let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; -let clientSettingsHydrated = false; +let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending"; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; let clientSettingsPersistenceQueue: Promise = Promise.resolve(); +let deferredClientSettingsPatchCount = 0; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { emitClientSettingsChange(); } -function setClientSettingsHydrated(nextHydrated: boolean): void { - if (clientSettingsHydrated === nextHydrated) { +function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void { + if (clientSettingsHydrationStatus === nextStatus) { return; } - clientSettingsHydrated = nextHydrated; + clientSettingsHydrationStatus = nextStatus; emitClientSettingsHydrationChange(); } function subscribeClientSettings(listener: () => void): () => void { clientSettingsListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsListeners.delete(listener); }; } function getClientSettingsHydratedSnapshot(): boolean { - return clientSettingsHydrated; + return clientSettingsHydrationStatus === "ready"; +} + +function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus { + return clientSettingsHydrationStatus; } function subscribeClientSettingsHydration(listener: () => void): () => void { clientSettingsHydrationListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsHydrationListeners.delete(listener); }; } async function hydrateClientSettings(): Promise { - if (clientSettingsHydrated) { + if (clientSettingsHydrationStatus === "ready") { return; } if (clientSettingsHydrationPromise) { @@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise { } const hydrationGeneration = clientSettingsHydrationGeneration; + setClientSettingsHydrationStatus( + clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying" + ? "retrying" + : "pending", + ); const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); @@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise { if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } + setClientSettingsHydrationStatus("ready"); } catch (error) { + if (hydrationGeneration === clientSettingsHydrationGeneration) { + setClientSettingsHydrationStatus("failed"); + } console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { operation: "hydrate", ...safeErrorLogAttributes(error), }); - } finally { - if (hydrationGeneration === clientSettingsHydrationGeneration) { - setClientSettingsHydrated(true); - } + throw error; } })(); @@ -165,15 +177,32 @@ export function persistClientSettingsPatch( patch: ClientSettingsPatch, persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): void { - replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); - void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch( - (error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { - operation: "persist", - ...safeErrorLogAttributes(error), - }); - }, - ); + // Patches queued before hydration must publish before newer optimistic patches. + const deferPatch = + clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; + if (deferPatch) { + deferredClientSettingsPatchCount += 1; + } else { + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } + void enqueueClientSettingsPersistence(async () => { + if (deferPatch) { + try { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } finally { + deferredClientSettingsPatchCount -= 1; + } + } + await persist(getClientSettingsSnapshot()); + }).catch((error) => { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); + }); } /** @@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate( persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): Promise { return enqueueClientSettingsPersistence(async () => { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } for (;;) { const current = getClientSettingsSnapshot(); const next = update(current); @@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings { } /** - * Resolves once client settings have been read from disk. + * Resolves after settings load or storage confirms no saved settings exist. + * Failed reads reject and remain retryable. They must not allow defaults to + * overwrite saved preferences. * * The pre-hydration snapshot is just the schema defaults, so imperative paths * that open a preview must await this or they bake the built-in viewport, zoom @@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean { ); } +export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus { + return useSyncExternalStore( + subscribeClientSettingsHydration, + getClientSettingsHydrationStatusSnapshot, + () => "pending", + ); +} + function useClientSettingsValue(): ClientSettings { return useSyncExternalStore( subscribeClientSettings, @@ -524,9 +566,10 @@ export function useUpdateClientSettings() { export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; - clientSettingsHydrated = false; + clientSettingsHydrationStatus = "pending"; clientSettingsHydrationPromise = null; clientSettingsPersistenceQueue = Promise.resolve(); + deferredClientSettingsPatchCount = 0; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } @@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void { export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = settings; - clientSettingsHydrated = true; + clientSettingsHydrationStatus = "ready"; clientSettingsHydrationPromise = null; } diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index ab87388ff298..9a1748dc4d24 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -204,3 +204,200 @@ describe("theme failure handling", () => { } }); }); + +describe("onboarding theme", () => { + it("clears custom palettes and restores the latest selected theme", async () => { + const storage = createStorage(); + const classes = new Set(); + const styleValues = new Map(); + const root = { + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style: { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }, + }; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: () => undefined, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + vi.stubGlobal("document", { + body: { style: { backgroundColor: "" } }, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: "rgb(0, 0, 0)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { EMBER_THEME, installCustomTheme } = await import("../themePalette"); + const firstTheme = installCustomTheme({ + ...EMBER_THEME, + id: "first-custom", + label: "First Custom", + }); + const secondTheme = installCustomTheme({ + ...EMBER_THEME, + id: "second-custom", + label: "Second Custom", + colors: { ...EMBER_THEME.colors, error: "#123456" }, + }); + storage.setItem("t3code:theme", firstTheme.id); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(root.dataset.themeId).toBe(firstTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(firstTheme.colors.error); + + const cleanup = mountOnboardingTheme(); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + expect(useTheme().setTheme(secondTheme.id)).toBe(true); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + cleanup(); + expect(root.dataset.themeId).toBe(secondTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error); + }); + + it("stays dark during storage changes and restores the latest saved theme", async () => { + const storage = createStorage(); + storage.setItem("t3code:theme", "light"); + const classes = new Set(); + const styleValues = new Map(); + const style = { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }; + const root = { + classList: { + add: (name: string) => classes.add(name), + contains: (name: string) => classes.has(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style, + }; + const body = { style: { backgroundColor: "" } }; + let storageHandler: ((event: StorageEvent) => void) | undefined; + const setDesktopTheme = vi.fn().mockResolvedValue(undefined); + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + desktopBridge: { setTheme: setDesktopTheme }, + }); + vi.stubGlobal("document", { + body, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: + root.dataset.onboardingSurface !== undefined + ? "rgb(0, 0, 0)" + : classes.has("dark") + ? "rgb(10, 10, 10)" + : "rgb(255, 255, 255)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(useTheme().resolvedTheme).toBe("light"); + const cleanup = mountOnboardingTheme(); + + expect(root.dataset.onboardingSurface).toBe(""); + expect(classes.has("dark")).toBe(true); + expect(root.style.backgroundColor).toBe("#000"); + expect(body.style.backgroundColor).toBe("#000"); + expect(useTheme().resolvedTheme).toBe("dark"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("dark"); + + storage.setItem("t3code:theme", "dark"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + storage.setItem("t3code:theme", "light"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + expect(classes.has("dark")).toBe(true); + expect(useTheme().resolvedTheme).toBe("dark"); + + cleanup(); + expect(root.dataset.onboardingSurface).toBeUndefined(); + expect(classes.has("dark")).toBe(false); + expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(storage.getItem("t3code:theme")).toBe("light"); + expect(useTheme().resolvedTheme).toBe("light"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("light"); + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 726a03dac7b8..01928552acb0 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { function themeHalvesSignature(halves: ThemeHalves | null): string { return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; } + +function isOnboardingThemeActive(): boolean { + return ( + typeof document !== "undefined" && + document.documentElement.dataset?.onboardingSurface !== undefined + ); +} + const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; @@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement { export function syncBrowserChromeTheme() { if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); const rootStyles = getComputedStyle(document.documentElement); - const themeChromeColor = document.documentElement.dataset.themeId - ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) - : null; + const themeChromeColor = + !onboardingActive && document.documentElement.dataset.themeId + ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) + : null; const surfaceColor = normalizeThemeColor( getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, ); const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; + const backgroundColor = onboardingActive + ? "#000" + : (themeChromeColor ?? surfaceColor ?? fallbackColor); if (!backgroundColor) return; document.documentElement.style.backgroundColor = backgroundColor; @@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() { function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { if (typeof document === "undefined" || typeof window === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); // Keep the editor's draft visible until an explicit refresh restores the selection. - if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return; + if ( + preservePreview && + !onboardingActive && + document.documentElement.dataset?.themeId === THEME_PREVIEW_ID + ) { + return; + } const appearanceMode = readAppearanceModePreference(theme); const followSystem = appearanceMode === "system"; const systemDark = followSystem ? getSystemDark() : false; @@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview lastAppliedTheme.appearanceMode === appearanceMode && themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) ) { - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } return; } @@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview appearanceMode, themeHalves, ); - applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); - const isDark = resolvedAppearance === "dark"; - document.documentElement.classList.toggle("dark", isDark); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + } else { + applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); + document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); + } lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncBrowserChromeTheme(); - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal void document.documentElement.offsetHeight; @@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview } } +/** Own the document-wide dark palette used by the first-run wizard and its portals. */ +export function mountOnboardingTheme(): () => void { + if (typeof document === "undefined" || typeof window === "undefined") return () => {}; + + const root = document.documentElement; + applyThemePalette("dark", "dark"); + root.dataset.onboardingSurface = ""; + root.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + emitChange(); + + return () => { + delete root.dataset.onboardingSurface; + root.style.backgroundColor = ""; + document.body.style.backgroundColor = ""; + lastAppliedTheme = null; + applyTheme(getStored(), { suppressTransitions: true, preservePreview: false }); + emitChange(); + }; +} + export async function syncDesktopThemePreference( bridge: DesktopThemeBridge, theme: Theme, @@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot { const systemDark = followSystem ? getSystemDark() : false; const themeHalves = readStoredThemeHalves(); - const resolvedTheme = resolveThemeAppearance( - theme, - systemDark, - followSystem, - appearanceMode, - themeHalves, - ); + const resolvedTheme = isOnboardingThemeActive() + ? "dark" + : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves); if ( lastSnapshot && lastSnapshot.theme === theme && diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 68b0101be28a..45d10f28d735 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) { --terminal-selection-background: var(--app-theme-terminal-selection-background); } +/* The first-run flow owns the whole document so portaled menus and tooltips + use the same fixed palette as the wizard. This follows the theme mapping so + saved custom themes cannot override it while onboarding is mounted. */ +html[data-onboarding-surface]:root { + color-scheme: dark; + --accent: #262626; + --accent-foreground: #fff; + --appearance-contrast-target: #fff; + --app-chrome-background: #000; + --background: #000; + --border: #262626; + --card: #000; + --card-foreground: #fff; + --destructive: var(--color-red-400); + --foreground: #fff; + --icon-muted: #a1a1aa; + --input: #262626; + --muted: #171717; + --muted-foreground: #a1a1aa; + --placeholder: #71717a; + --popover: #171717; + --popover-foreground: #fff; + --ring: #737373; + --secondary: #171717; + --secondary-foreground: #fff; + --secondary-label: #a1a1aa; + --success-foreground: var(--color-emerald-400); + --terminal-background: #000; + --terminal-cursor: #fff; + --terminal-foreground: #fff; + --terminal-selection-background: rgb(255 255 255 / 20%); +} + /* Theme-token dependency probes are restored synchronously, before paint. Keep transitions from observing the temporary sentinel color in between. */ html[data-theme-token-probe], @@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] { } } -/* Contrast stays in ordinary custom properties so both Tailwind utilities and - global/imperative chrome styles resolve the same adjusted role. Redeclare on - the sidebar because it owns a local semantic palette. */ +/* Recompute contrast wherever a subtree owns its own semantic color palette. */ :root, -[data-app-sidebar] { +[data-app-sidebar], +[data-onboarding-surface] { --contrast-toolbar-foreground: color-mix( in oklab, color-mix( diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts new file mode 100644 index 000000000000..ca35f6322e3b --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -0,0 +1,514 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, +} from "./firstRun.logic"; + +const freshWorkspace = { + enabled: true, + hydrated: true, + completed: false, + bootstrapped: true, + authoritative: true, + workspaceAuthoritative: true, + workspaceProvenanceAuthoritative: true, + catalogReady: true, + serverConfigAvailable: true, + workspaceFresh: true, + projectCount: 1, + threadCount: 1, +} as const; + +describe("resolveFirstRunDecision", () => { + it("opens the wizard for an authoritative fresh workspace", () => { + expect(resolveFirstRunDecision(freshWorkspace)).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("does not permanently complete onboarding from cached project counts", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("backfills completion once existing projects are confirmed by the server", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("does not complete onboarding while another environment is still bootstrapping", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + bootstrapped: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for managed environments before treating a workspace as new", () => { + expect(resolveFirstRunDecision({ ...freshWorkspace, catalogReady: false })).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the environment catalog is ready", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + catalogReady: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the server configuration is available", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + serverConfigAvailable: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from cached remote projects", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from a single cached remote project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for live data before judging a single cached project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the completed bootstrap welcome before judging a nonempty workspace", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits when the initial welcome is pending and opens the wizard after completion", () => { + const pendingProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: pendingProvenance, + }), + ).toEqual({ decision: "pending", persistCompletion: false }); + + const completedProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: completedProvenance, + }), + ).toEqual({ decision: "wizard", persistCompletion: false }); + }); + + it("does not wait for server data after onboarding is already complete", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + bootstrapped: false, + completed: true, + serverConfigAvailable: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +describe("isFirstRunWorkspaceProvenanceAuthoritative", () => { + it("waits for cwd bootstrap when the initial catalog is empty", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }), + ).toBe(false); + }); + + it("accepts an empty catalog after cwd bootstrap completes", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }), + ).toBe(true); + }); + + it("waits for a welcome before treating an empty catalog as final", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: false, + bootstrapStatus: null, + }), + ).toBe(false); + }); + + it("accepts a legacy welcome without bootstrap status", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: null, + }), + ).toBe(true); + }); +}); + +describe("transitionFirstRunGateState", () => { + it("shows recovery without mounting the app when evidence stalls", () => { + expect( + transitionFirstRunGateState({ decision: "pending", stalled: false }, { type: "timeout" }), + ).toEqual({ decision: "pending", stalled: true }); + }); + + it.each(["app", "wizard"] as const)( + "resolves stalled recovery to %s only after authoritative evidence", + (decision) => { + expect( + transitionFirstRunGateState( + { decision: "pending", stalled: true }, + { type: "evidence", decision }, + ), + ).toEqual({ decision, stalled: false }); + }, + ); + + it("keeps recovery visible while evidence remains pending", () => { + const state = { decision: "pending", stalled: true } as const; + expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe( + state, + ); + }); + + it("allows authoritative wizard evidence to replace an app decision", () => { + expect( + transitionFirstRunGateState( + { decision: "app", stalled: false }, + { type: "evidence", decision: "wizard" }, + ), + ).toEqual({ decision: "wizard", stalled: false }); + }); +}); + +describe("resolveHostedFirstRunDecision", () => { + it("keeps the shell hidden until client settings are hydrated", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: false, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the saved environment catalog before judging a hosted install", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("opens onboarding when a hosted install has no saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("backfills onboarding for a hosted install with saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 1, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("opens the app immediately after hosted onboarding is complete", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: true, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +const primaryEnvironmentId = "primary-environment"; +const bootstrapProject = { + id: "bootstrap-project", + environmentId: primaryEnvironmentId, + workspaceRoot: "/projects/current", +}; +const bootstrapThread = { + id: "bootstrap-thread", + projectId: bootstrapProject.id, + environmentId: primaryEnvironmentId, + latestTurn: null, + latestUserMessageAt: null, + session: null, +}; + +describe("isFreshFirstRunWorkspace", () => { + it("accepts an empty workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [], + }), + ).toBe(true); + }); + + it("accepts only the unused project and thread created from the server cwd", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current/", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects an existing unused cwd project and thread reused by startup", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a nonempty workspace when an older server omits creation provenance", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("normalizes Windows project paths before checking the bootstrap workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "C:\\Projects\\Current\\", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [{ ...bootstrapProject, workspaceRoot: "c:/projects/current" }], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects projects from another environment even when their paths match", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [{ ...bootstrapProject, environmentId: "remote-environment" }], + threads: [], + }), + ).toBe(false); + }); + + it("rejects threads from another environment", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, environmentId: "remote-environment" }], + }), + ).toBe(false); + }); + + it("rejects a thread that does not belong to the bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, projectId: "another-project" }], + }), + ).toBe(false); + }); + + it("rejects a thread when there is no bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that already has a user message", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [ + { + ...bootstrapThread, + latestUserMessageAt: "2026-08-23T12:00:00.000Z", + }, + ], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has started a turn", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, latestTurn: { id: "first-turn" } }], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has a provider session", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, session: { status: "ready" } }], + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts new file mode 100644 index 000000000000..013dbb02d527 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -0,0 +1,184 @@ +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +export type FirstRunDecision = "pending" | "app" | "wizard"; + +export interface FirstRunGateState { + readonly decision: FirstRunDecision; + readonly stalled: boolean; +} + +type FirstRunGateEvent = + | { readonly type: "evidence"; readonly decision: FirstRunDecision } + | { readonly type: "timeout" }; + +interface FirstRunWorkspaceInput { + readonly primaryEnvironmentId: string | null; + readonly serverCwd: string | null; + readonly bootstrapProjectId?: string | undefined; + readonly bootstrapThreadId?: string | undefined; + readonly bootstrapProjectCreated?: boolean | undefined; + readonly bootstrapThreadCreated?: boolean | undefined; + readonly projects: ReadonlyArray<{ + readonly id: string; + readonly environmentId: string; + readonly workspaceRoot: string; + }>; + readonly threads: ReadonlyArray<{ + readonly id: string; + readonly projectId: string; + readonly environmentId: string; + readonly latestTurn: unknown; + readonly latestUserMessageAt: string | null; + readonly session: unknown; + }>; +} + +interface FirstRunDecisionInput { + readonly enabled: boolean; + readonly hydrated: boolean; + readonly completed: boolean; + readonly bootstrapped: boolean; + readonly authoritative: boolean; + readonly workspaceAuthoritative: boolean; + readonly workspaceProvenanceAuthoritative: boolean; + readonly catalogReady: boolean; + readonly serverConfigAvailable: boolean; + readonly workspaceFresh: boolean; + readonly projectCount: number; + readonly threadCount: number; +} + +interface HostedFirstRunDecisionInput { + readonly hydrated: boolean; + readonly completed: boolean; + readonly catalogReady: boolean; + readonly environmentCount: number; +} + +export function isFirstRunWorkspaceProvenanceAuthoritative(input: { + readonly welcomeReceived: boolean; + readonly bootstrapStatus: "pending" | "complete" | null; +}): boolean { + // An empty catalog is not final while cwd auto-bootstrap is pending. Older + // servers omit bootstrapStatus, so a received welcome with null stays valid. + return input.welcomeReceived && input.bootstrapStatus !== "pending"; +} + +/** Keeps the authenticated app unmounted until workspace evidence settles. */ +export function transitionFirstRunGateState( + state: FirstRunGateState, + event: FirstRunGateEvent, +): FirstRunGateState { + if (event.type === "timeout") { + return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; + } + + if ( + state.decision === "wizard" || + event.decision === "pending" || + (state.decision === "app" && event.decision !== "wizard") + ) { + return state; + } + + return { decision: event.decision, stalled: false }; +} + +/** Only a project and thread created by this startup count as a fresh nonempty workspace. */ +export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean { + if (input.projects.length > 1 || input.threads.length > 1) { + return false; + } + + const bootstrapProject = input.projects[0]; + if (bootstrapProject !== undefined) { + if ( + input.bootstrapProjectCreated !== true || + input.bootstrapProjectId !== bootstrapProject.id || + input.serverCwd === null || + bootstrapProject.environmentId !== input.primaryEnvironmentId || + normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !== + normalizeProjectPathForComparison(input.serverCwd) + ) { + return false; + } + } + + const bootstrapThread = input.threads[0]; + if (bootstrapThread === undefined) { + return true; + } + + return ( + bootstrapProject !== undefined && + input.bootstrapThreadCreated === true && + input.bootstrapThreadId === bootstrapThread.id && + bootstrapThread.environmentId === input.primaryEnvironmentId && + bootstrapThread.projectId === bootstrapProject.id && + bootstrapThread.latestTurn === null && + bootstrapThread.latestUserMessageAt === null && + bootstrapThread.session === null + ); +} + +/** Cached projects may open the app, but only live workspace data may complete onboarding. */ +export function resolveFirstRunDecision(input: FirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.enabled || (input.hydrated && input.completed)) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.projectCount > 1 || input.threadCount > 1) { + return { + decision: "app", + persistCompletion: + input.bootstrapped && + input.authoritative && + input.workspaceAuthoritative && + input.catalogReady && + input.serverConfigAvailable, + }; + } + + if ( + !input.bootstrapped || + !input.authoritative || + !input.workspaceProvenanceAuthoritative || + !input.catalogReady || + !input.serverConfigAvailable + ) { + return { decision: "pending", persistCompletion: false }; + } + + return input.workspaceFresh + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: input.workspaceAuthoritative }; +} + +/** Hosted onboarding depends on saved environments because there is no primary server. */ +export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.completed) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.catalogReady) { + return { decision: "pending", persistCompletion: false }; + } + + return input.environmentCount === 0 + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: true }; +} diff --git a/apps/web/src/onboarding/firstRun.ts b/apps/web/src/onboarding/firstRun.ts new file mode 100644 index 000000000000..4daafc7cc924 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; + +import { ensureClientSettingsHydrated, persistClientSettingsUpdate } from "../hooks/useSettings"; + +/** + * Marks first-run onboarding finished (or skipped) so FirstRunGate never + * routes to the welcome wizard again. The gate itself lives in + * `components/onboarding/FirstRunGate.tsx`. + */ +export function useCompleteOnboarding(): () => Promise { + return useCallback(async () => { + await ensureClientSettingsHydrated(); + const onboardingCompletedAt = new Date().toISOString(); + await persistClientSettingsUpdate((current) => ({ ...current, onboardingCompletedAt })); + }, []); +} diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts new file mode 100644 index 000000000000..07028abd28b4 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -0,0 +1,245 @@ +import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "./projectImport.logic"; + +const now = Date.parse("2026-08-22T12:00:00.000Z"); + +function candidate( + path: string, + overrides: Partial = {}, +): AgentSessionProjectCandidate { + return { + title: path.split("/").at(-1) ?? path, + path, + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-08-20T12:00:00.000Z", + alreadyImported: false, + ...overrides, + }; +} + +describe("partitionOnboardingProjects", () => { + it("keeps existing projects available for thread history import", () => { + const imported = candidate("/projects/current", { alreadyImported: true }); + const available = candidate("/projects/other"); + + expect(partitionOnboardingProjects([imported, available], now)).toEqual({ + available: [imported, available], + recent: [imported, available], + }); + }); + + it("keeps projects older than 30 days out of the default selection", () => { + const recent = candidate("/projects/recent"); + const older = candidate("/projects/older", { + lastActiveAt: "2026-07-01T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, older], now)).toEqual({ + available: [recent, older], + recent: [recent], + }); + }); + + it("keeps future activity out of the default selection", () => { + const recent = candidate("/projects/recent"); + const future = candidate("/projects/future", { + lastActiveAt: "2026-08-23T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, future], now)).toEqual({ + available: [recent, future], + recent: [recent], + }); + }); +}); + +describe("resolveOnboardingProjectId", () => { + const localEnvironmentId = EnvironmentId.make("local"); + const remoteEnvironmentId = EnvironmentId.make("remote"); + const localProjectId = ProjectId.make("local-project"); + + it("uses the scanned project ID before the project reaches the client", () => { + expect( + resolveOnboardingProjectId( + [], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("uses the scanned project ID when the client still has an older project at that root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("stale-project"), + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("returns null to create a project when neither the scan nor the client has a project ID", () => { + expect( + resolveOnboardingProjectId([], localEnvironmentId, candidate("/projects/new")), + ).toBeNull(); + }); + + it("finds an existing project by normalized root in the target environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "C:\\Work\\Repo", + }, + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "C:\\Work\\Repo\\", + }, + ], + localEnvironmentId, + candidate("c:/work/repo"), + ), + ).toBe(localProjectId); + }); + + it("does not reuse a project from another environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); + + it("finds an alias after the scanner returns its persisted project root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/real/projects/repo", + }, + ], + localEnvironmentId, + candidate("/real/projects/repo"), + ), + ).toBe(localProjectId); + }); + + it("finds the current root owner when the scan has no project ID", () => { + const recreatedProjectId = ProjectId.make("recreated-project"); + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/other", + }, + { + id: recreatedProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBe(recreatedProjectId); + }); + + it("does not reuse a moved project when the scan has no project ID", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/moved", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); +}); + +describe("resolveOnboardingLandingProject", () => { + it("skips a failed first project for a later project with imported history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/failed", "/projects/imported"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("prefers a partial first import that added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/partial", "/projects/complete"], + new Map([["/projects/partial", "partial"]]), + new Map([["/projects/complete", "complete"]]), + ), + ).toBe("partial"); + }); + + it("uses a completed zero-history project when no import added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/empty", "/projects/failed"], + new Map(), + new Map([["/projects/empty", "empty"]]), + ), + ).toBe("empty"); + }); + + it("keeps an earlier successful import available on retry", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/imported", "/projects/retry"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("ignores cached successes outside the current retry selection", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/current"], + new Map([["/projects/previous", "previous"]]), + new Map([ + ["/projects/previous", "previous"], + ["/projects/current", "current"], + ]), + ), + ).toBe("current"); + }); +}); diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts new file mode 100644 index 000000000000..d723b911b665 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -0,0 +1,55 @@ +import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; +import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; + +const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +/** Existing projects still need their agent history imported, so every scan candidate is offered. */ +export function partitionOnboardingProjects( + candidates: ReadonlyArray, + now = Date.now(), +) { + const cutoff = now - RECENT_PROJECT_WINDOW_MS; + + return { + available: candidates, + recent: candidates.filter((candidate) => { + if (candidate.lastActiveAt === null) return false; + const lastActiveAt = Date.parse(candidate.lastActiveAt); + return lastActiveAt >= cutoff && lastActiveAt <= now; + }), + }; +} + +/** Use the server's project match before the client snapshot, which can lag behind the scan. */ +export function resolveOnboardingProjectId( + projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + }>, + environmentId: EnvironmentId, + candidate: Pick, +): ProjectId | null { + if (candidate.projectId !== undefined) return candidate.projectId; + const environmentProjects = projects.filter((project) => project.environmentId === environmentId); + const currentRootMatch = findProjectByPath(environmentProjects, candidate.path); + if (currentRootMatch !== undefined) return currentRootMatch.id; + return null; +} + +/** Prefer a selected project with imported history, then a completed empty import. */ +export function resolveOnboardingLandingProject( + selection: ReadonlyArray, + projectsWithImportedHistory: ReadonlyMap, + completedProjects: ReadonlyMap, +): T | undefined { + for (const path of selection) { + const project = projectsWithImportedHistory.get(path); + if (project !== undefined) return project; + } + for (const path of selection) { + const project = completedProjects.get(path); + if (project !== undefined) return project; + } + return undefined; +} diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts new file mode 100644 index 000000000000..ab742ac51b44 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -0,0 +1,317 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "./providerReadiness.logic"; + +const readyCodex: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getOnboardingProviderState", () => { + it("treats an enabled Codex provider with ready status and unknown authentication as ready", () => { + expect(getOnboardingProviderState(readyCodex)).toBe("ready"); + }); + + it("treats authenticated providers as ready only when their provider status is ready", () => { + expect(getOnboardingProviderState({ ...readyCodex, auth: { status: "authenticated" } })).toBe( + "ready", + ); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "error", + }), + ).toBe("attention"); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "warning", + }), + ).toBe("attention"); + }); + + it("offers sign-in only when the server reports an authentication failure", () => { + expect( + getOnboardingProviderState({ + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }), + ).toBe("signIn"); + expect(getOnboardingProviderState({ ...readyCodex, status: "error" })).toBe("attention"); + expect(getOnboardingProviderState({ ...readyCodex, status: "warning" })).toBe("attention"); + }); + + it("does not offer installation or sign-in for disabled providers", () => { + expect(getOnboardingProviderState({ ...readyCodex, enabled: false, installed: false })).toBe( + "disabled", + ); + expect(getOnboardingProviderState({ ...readyCodex, status: "disabled" })).toBe("disabled"); + }); + + it("offers installation only when an enabled provider is missing", () => { + expect(getOnboardingProviderState({ ...readyCodex, installed: false, status: "error" })).toBe( + "install", + ); + }); + + it("waits for a provider snapshot before offering an action", () => { + expect(getOnboardingProviderState(undefined)).toBe("checking"); + }); +}); + +describe("selectOnboardingProvidersByDriver", () => { + it("prefers a ready instance with unknown authentication to an unauthenticated instance", () => { + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([signedOutCodex, readyCodex]).get("codex")).toBe( + readyCodex, + ); + }); + + it("prefers a provider with an actionable sign-in over a failed provider", () => { + const failedCodex: ServerProvider = { ...readyCodex, status: "error" }; + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([failedCodex, signedOutCodex]).get("codex")).toBe( + signedOutCodex, + ); + }); + + it("prefers installed providers over missing or disabled instances", () => { + const disabledCodex: ServerProvider = { ...readyCodex, enabled: false }; + const missingCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + installed: false, + status: "error", + }; + + expect( + selectOnboardingProvidersByDriver([disabledCodex, missingCodex, readyCodex]).get("codex"), + ).toBe(readyCodex); + }); + + it("handles provider snapshots that have not arrived", () => { + expect(selectOnboardingProvidersByDriver(undefined).size).toBe(0); + }); + + it("keeps a ready custom account when the default account is signed out", () => { + const signedOutDefault: ServerProvider = { + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }; + const readyCustom: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + }; + + expect(selectOnboardingProvidersByDriver([signedOutDefault, readyCustom]).get("codex")).toBe( + readyCustom, + ); + }); +}); + +describe("resolveOnboardingProviderLoginCommand", () => { + it("uses the selected Codex account binary", () => { + const provider = { ...readyCodex, instanceId: ProviderInstanceId.make("codex_work") }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/codex-work/bin/codex" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/codex-work/bin/codex login"); + }); + + it("uses the selected Claude account binary", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude_work"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/claude-work/bin/claude" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/claude-work/bin/claude auth login"); + }); + + it("quotes a Codex path with spaces for PowerShell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("& 'C:\\Program Files\\Codex & Tools\\codex.exe' login"); + }); + + it("quotes a Claude path with shell metacharacters on POSIX", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { + ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, + binaryPath: "/opt/Claude Tools/$current/claude", + }, + }, + }, + "linux", + ), + ).toBe("'/opt/Claude Tools/$current/claude' auth login"); + }); + + it.each([ + ["~/my tools/codex", "~/'my tools/codex' login"], + ["~\\my tools/codex", "~/'my tools/codex' login"], + ["~/tools/codex's build", `~/'tools/codex'"'"'s build' login`], + ["~\\tools\\codex's build", `~/'tools\\codex'"'"'s build' login`], + ["~/tools/codex; echo unsafe", "~/'tools/codex; echo unsafe' login"], + ])("keeps the home prefix expandable while quoting %s", (binaryPath, expectedCommand) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath, + }, + }, + }, + "linux", + ), + ).toBe(expectedCommand); + }); + + it.each(["darwin", "linux"] as const)("quotes backslashes in a Codex path on %s", (platform) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/codex\\work/codex", + }, + }, + }, + platform, + ), + ).toBe("'/opt/codex\\work/codex' login"); + }); + + it("keeps a plain Windows path unquoted", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("C:\\Tools\\codex.exe login"); + }); + + it("uses the default command when an old server reports an unknown shell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/Codex Tools/codex", + }, + }, + }, + "unknown", + ), + ).toBe("codex login"); + }); +}); diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts new file mode 100644 index 000000000000..939b4c64cd64 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -0,0 +1,99 @@ +import { + ClaudeSettings, + CodexSettings, + type ExecutionEnvironmentPlatformOs, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/; + +function quoteProviderBinary( + binaryPath: string, + fallback: string, + platform: ExecutionEnvironmentPlatformOs, +): string { + if ( + SAFE_SHELL_BINARY_PATTERN.test(binaryPath) && + (platform === "windows" || !binaryPath.includes("\\")) + ) { + return binaryPath; + } + if (platform === "windows") return `& '${binaryPath.replaceAll("'", "''")}'`; + if (platform === "darwin" || platform === "linux") { + if (binaryPath.startsWith("~/") || binaryPath.startsWith("~\\")) { + return `~/'${binaryPath.slice(2).replaceAll("'", `'"'"'`)}'`; + } + return `'${binaryPath.replaceAll("'", `'"'"'`)}'`; + } + return fallback; +} + +export function getOnboardingProviderState(provider: ServerProvider | undefined) { + if (provider === undefined) return "checking"; + if (!provider.enabled || provider.status === "disabled") return "disabled"; + if (!provider.installed) return "install"; + if (provider.auth.status === "unauthenticated") return "signIn"; + if (provider.status === "ready") return "ready"; + return "attention"; +} + +const PROVIDER_STATE_PRIORITY = { + checking: 0, + disabled: 1, + install: 2, + attention: 3, + signIn: 4, + ready: 5, +} as const; + +/** Select the most usable configured instance for each provider driver. */ +export function selectOnboardingProvidersByDriver( + providers: ReadonlyArray | null | undefined, +) { + const providersByDriver = new Map(); + + for (const provider of providers ?? []) { + const existing = providersByDriver.get(provider.driver); + if ( + existing === undefined || + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(provider)] > + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(existing)] + ) { + providersByDriver.set(provider.driver, provider); + } + } + + return providersByDriver; +} + +/** Use the selected provider instance's binary when the setup terminal opens its login flow. */ +export function resolveOnboardingProviderLoginCommand( + provider: ServerProvider, + settings: ServerSettings, + platform: ExecutionEnvironmentPlatformOs, +): string { + const instance = settings.providerInstances[provider.instanceId]; + + if (provider.driver === "claudeAgent") { + const config = decodeClaudeSettings( + instance ? (instance.config ?? {}) : settings.providers.claudeAgent, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; + return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; + } + + if (provider.driver === "codex") { + const config = decodeCodexSettings( + instance ? (instance.config ?? {}) : settings.providers.codex, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; + return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; + } + + return provider.driver; +} diff --git a/apps/web/src/onboarding/targetEnvironment.logic.test.ts b/apps/web/src/onboarding/targetEnvironment.logic.test.ts new file mode 100644 index 000000000000..9928f83b27db --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.test.ts @@ -0,0 +1,211 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "./targetEnvironment.logic"; + +const primaryEnvironment = { + environmentId: EnvironmentId.make("primary"), + connection: { phase: "connected" }, + entry: { + target: new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("primary"), + label: "This computer", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }), + }, + label: "This computer", +} as const; + +const olderRelay = { + environmentId: EnvironmentId.make("older-remote"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("older-remote"), + label: "Older computer", + }), + }, + label: "Older computer", +} as const; + +const newerRelay = { + environmentId: EnvironmentId.make("newer-relay"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("newer-relay"), + label: "New computer", + }), + }, + label: "New computer", +} as const; + +const pairedRemote = { + environmentId: EnvironmentId.make("paired-remote"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("paired-remote"), + label: "Direct computer", + connectionId: "paired-remote", + }), + }, + label: "Direct computer", +} as const; + +const sshEnvironment = { + environmentId: EnvironmentId.make("ssh-remote"), + connection: { phase: "connected" }, + entry: { + target: new SshConnectionTarget({ + environmentId: EnvironmentId.make("ssh-remote"), + label: "SSH computer", + connectionId: "ssh-remote", + }), + }, + label: "SSH computer", +} as const; + +const desktopLocalEnvironment = { + environmentId: EnvironmentId.make("desktop-local-wsl"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("desktop-local-wsl"), + label: "WSL", + connectionId: "local:wsl:Ubuntu", + }), + }, + label: "WSL", +} as const; + +describe("resolveOnboardingTargetEnvironment", () => { + it("waits for the exact paired machine instead of using an older connected machine", () => { + const pendingPairedRemote = { ...pairedRemote, connection: { phase: "connecting" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pendingPairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the exact paired machine once it connects", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBe(pairedRemote); + }); + + it("waits for a newly paired machine that has not appeared in the catalog", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the primary machine for local onboarding", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("does not substitute a remote machine when the local primary is offline", () => { + const offlinePrimary = { ...primaryEnvironment, connection: { phase: "disconnected" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [offlinePrimary, olderRelay], + primaryEnvironment: offlinePrimary, + pairedEnvironmentId: null, + }), + ).toBeNull(); + }); + + it("uses the newest connected remote when no exact machine was selected", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, olderRelay, newerRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(newerRelay); + }); + + it("ignores direct, SSH, and desktop-managed connections in Connect mode", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [ + primaryEnvironment, + olderRelay, + pairedRemote, + sshEnvironment, + desktopLocalEnvironment, + ], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(olderRelay); + }); + + it("uses the primary computer when no relay connection exists", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, pairedRemote, sshEnvironment, desktopLocalEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("falls back to the connected primary when no remote is available", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); +}); + +describe("isOnboardingRelayEnvironment", () => { + it("includes only T3 Connect relay targets", () => { + expect( + [olderRelay, pairedRemote, sshEnvironment, desktopLocalEnvironment].filter( + isOnboardingRelayEnvironment, + ), + ).toEqual([olderRelay]); + }); +}); diff --git a/apps/web/src/onboarding/targetEnvironment.logic.ts b/apps/web/src/onboarding/targetEnvironment.logic.ts new file mode 100644 index 000000000000..6045b8c441e2 --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.ts @@ -0,0 +1,49 @@ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; + +interface OnboardingEnvironment { + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; + readonly entry: { readonly target: ConnectionTarget }; +} + +export function isOnboardingRelayEnvironment( + environment: Pick, +): boolean { + return environment.entry.target._tag === "RelayConnectionTarget"; +} + +/** Keep a directly paired machine pinned while its initial connection completes. */ +export function resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, +}: { + readonly mode: "local" | "connect" | "direct"; + readonly environments: ReadonlyArray; + readonly primaryEnvironment: TEnvironment | null; + readonly pairedEnvironmentId: EnvironmentId | null; +}): TEnvironment | null { + if (mode === "direct" && pairedEnvironmentId !== null) { + const pairedEnvironment = environments.find( + (environment) => environment.environmentId === pairedEnvironmentId, + ); + return pairedEnvironment?.connection.phase === "connected" ? pairedEnvironment : null; + } + + const connectedRelayEnvironments = environments.filter( + (environment) => + environment.connection.phase === "connected" && isOnboardingRelayEnvironment(environment), + ); + + if (mode === "connect" && connectedRelayEnvironments.length > 0) { + return connectedRelayEnvironments[connectedRelayEnvironments.length - 1] ?? null; + } + + if (primaryEnvironment?.connection.phase === "connected") { + return primaryEnvironment; + } + + return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..5c796f3ab6c8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' @@ -30,6 +31,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const WelcomeRoute = WelcomeRouteImport.update({ + id: '/welcome', + path: '/welcome', + getParentRoute: () => rootRouteImport, +} as any) const UsageRoute = UsageRouteImport.update({ id: '/usage', path: '/usage', @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -157,6 +164,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -180,6 +188,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/connect_/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -204,6 +213,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -224,6 +234,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -246,6 +257,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/welcome' | '/_chat/pull-requests' | '/connect_/callback' | '/projects/$projectKey' @@ -269,12 +281,20 @@ export interface RootRouteChildren { PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute + WelcomeRoute: typeof WelcomeRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/welcome': { + id: '/welcome' + path: '/welcome' + fullPath: '/welcome' + preLoaderRoute: typeof WelcomeRouteImport + parentRoute: typeof rootRouteImport + } '/usage': { id: '/usage' path: '/usage' @@ -468,6 +488,7 @@ const rootRouteChildren: RootRouteChildren = { PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, + WelcomeRoute: WelcomeRoute, ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 64e858d186eb..12cdd946f6c4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; +import { FirstRunGate } from "../components/onboarding/FirstRunGate"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; @@ -97,6 +98,13 @@ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; + const returningFromWelcomeRef = useRef(pathname === "/welcome"); + + useEffect(() => { + if (pathname === "/welcome") { + returningFromWelcomeRef.current = true; + } + }, [pathname]); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -116,6 +124,19 @@ function RootRouteView() { ); } + // The welcome wizard is full-screen like /pair, but keeps toasts so its + // connect/import actions can report failures. + if (pathname === "/welcome") { + return ( + + + + + + + ); + } + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ( <> @@ -133,6 +154,10 @@ function RootRouteView() { ); + // FirstRunGate holds back everything below it — including EventRouter, + // whose welcome payload navigates into a thread — until the first-run + // decision is known, so a fresh install renders nothing (not the shell, + // not a flash of threads) before landing on the welcome wizard. return ( @@ -141,21 +166,28 @@ function RootRouteView() { - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - - - - - - - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {appShell} - {/* Above the router: a theme draft is judged by walking the app, so the - editor has to survive navigation away from settings. */} - + + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + + + + + + + {primaryEnvironmentAuthenticated ? ( + + ) : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {appShell} + {/* Above the router: a theme draft is judged by walking the app, so the + editor has to survive navigation away from settings. */} + + ); @@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() { return null; } -function EventRouter() { +function EventRouter({ + skipInitialBootstrapNavigation, +}: { + readonly skipInitialBootstrapNavigation: boolean; +}) { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -394,6 +430,7 @@ function EventRouter() { const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); + const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation); const handledConfigEventRef = useRef(serverConfigEvent); const [keybindingsToastController] = useState(() => createKeybindingsUpdateToastController({}), @@ -425,6 +462,11 @@ function EventRouter() { if (readPathname() !== "/") { return; } + if (skipInitialBootstrapNavigationRef.current) { + skipInitialBootstrapNavigationRef.current = false; + handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; + return; + } if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { return; } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index ba96e8986a97..6a53ee024548 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -21,10 +21,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); - const { environments } = useEnvironments(); + const { environments, isReady } = useEnvironments(); - if (authGateState.status === "hosted-static" && environments.length === 0) { - return ; + if (authGateState.status === "hosted-static") { + if (!isReady) return null; + if (environments.length === 0) return ; } return ; @@ -79,6 +80,8 @@ function IndexDraftLanding() { /> ) : null; } + // First-run routing to the welcome wizard happens in FirstRunGate at the + // root, before this route ever renders. return ; } diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx new file mode 100644 index 000000000000..10caa4dd46fb --- /dev/null +++ b/apps/web/src/routes/welcome.tsx @@ -0,0 +1,45 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; + +import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; + +/** + * First-run welcome wizard. Full-screen, outside the sidebar shell (the root + * route mounts this path bare, like /pair). Reached only via the first-run + * gate on the index route; visiting it directly after onboarding is harmless — + * finishing again just refreshes the completion flag. + */ +export const Route = createFileRoute("/welcome")({ + beforeLoad: ({ context }) => { + const { authGateState } = context; + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: WelcomeRouteView, +}); + +function WelcomeRouteView() { + const { authGateState } = Route.useRouteContext(); + const navigate = useNavigate(); + const openNewThread = useNewThreadHandler(); + // An authenticated gate means a primary server is serving this app — + // desktop, `npx t3`, or a dev server — and that server is "this machine" + // no matter what hostname the browser used. Only hosted-static has no + // local server to offer. + const localAvailable = authGateState.status === "authenticated"; + return ( + { + if (projectRef !== undefined) { + void openNewThread(projectRef, { replace: true }).catch(() => { + void navigate({ to: "/", replace: true }); + }); + return; + } + void navigate({ to: "/", replace: true }); + }} + /> + ); +} diff --git a/apps/web/src/state/agentSessions.ts b/apps/web/src/state/agentSessions.ts new file mode 100644 index 000000000000..996ddb0ea730 --- /dev/null +++ b/apps/web/src/state/agentSessions.ts @@ -0,0 +1,25 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** + * Scan of Claude Code / Codex home directories on an environment, surfacing + * project candidates for the welcome wizard's import step. The scan walks the + * filesystem server-side, so results are cached briefly and refreshed when the + * import step remounts. + */ +export const agentSessionScan = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:agent-sessions:scan", + tag: WS_METHODS.agentSessionsScan, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, +}); + +export const agentSessionImport = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:agent-sessions:import", + tag: WS_METHODS.agentSessionsImport, +}); diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md new file mode 100644 index 000000000000..9a2aa116670d --- /dev/null +++ b/docs/user/welcome-wizard.md @@ -0,0 +1,62 @@ +# Welcome wizard + +T3 Code shows a setup flow when you open a new installation or connect to the +hosted app for the first time. Existing workspaces skip this flow. + +## Choose a connection + +- **This computer** runs agents on the computer that hosts T3 Code. It does not + require an account. +- **T3 Connect** connects computers that are signed in to your account. Run + `npx t3 connect` on each computer you want to add, then start T3 Code or run + `npx t3 serve` so the computer stays available. +- **Pair a server** connects directly to a server on your network or tailnet. + Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and + paste the pairing link. You can also run `npx t3 serve --host
` and + use `npx t3 pair` when the server is already reachable on your network. + +If T3 Code cannot confirm the workspace during startup, the setup flow shows +**Still connecting** instead of opening the app. Select **Reload** to try again. + +If T3 Code cannot read your saved settings, it shows **Could not read settings**. +Select **Retry** after storage becomes available. Setup does not replace +unreadable settings with defaults. + +## Check your agents + +T3 Code checks the selected computer for Claude Code and Codex. If an agent is +not installed or signed in, select its action to open a terminal with the +correct command ready to run. Other providers can be enabled in Settings. + +The setup terminal uses the home directory and environment configured for the +selected provider instance. Sensitive values remain redacted in Settings and +terminal metadata while the terminal process can use them. + +## Import your projects + +T3 Code finds directories that Claude Code or Codex has used. The default +selection includes projects active within the last 30 days. Select **Choose** +to include older projects or change the selection. + +A large or malformed history can reach the scan limit. T3 Code keeps the +projects it found and warns when projects or conversations may be missing. + +Imported projects include Codex and Claude conversations active within the last +30 days. You can continue those conversations in T3 Code. + +Conversation import is best effort. T3 Code keeps the first user prompt and the +newest remaining visible user and assistant messages, with 200 messages total. +It omits tool activity and attachments. For Codex, it omits generated setup +context only when a canonical user event and a valid shared turn ID identify the +same user turn. Ambiguous legacy or response-only context stays in the imported +conversation so T3 Code does not remove user text. It reads one conversation at +a time and skips files larger than 16 MiB. It ignores malformed records and skips +unreadable or unparseable conversations. + +Each import attempt reads up to 100 conversation files and 64 MiB per project, +with up to 100,000 input records. Run import again to continue a large batch. +Completed conversations are not imported again. You can continue without the +remaining history. + +You can skip agent setup and project import. Select **Back** to return to a +previous step. diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 4e6baba8bef4..f2141add930f 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -3,10 +3,12 @@ import { EnvironmentId, type RelayClientInstallProgressEvent, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -27,7 +29,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + request, + runStream, + subscribe, + subscribeDynamicWithSession, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -221,6 +229,72 @@ describe("environment RPC", () => { }), ); + it.effect("keeps the producer session on an old value buffered across a session switch", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const secondSubscribed = yield* Deferred.make(); + const firstValueBlocked = yield* Deferred.make(); + const releaseFirstValue = yield* Deferred.make(); + const firstValue = { source: "first", index: 1 } as unknown as ServerLifecycleStreamEvent; + const bufferedFirstValue = { + source: "first", + index: 2, + } as unknown as ServerLifecycleStreamEvent; + const secondValue = { source: "second", index: 1 } as unknown as ServerLifecycleStreamEvent; + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromIterable([firstValue, bufferedFirstValue])), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(secondSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.make(secondValue)), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const { activeSession, supervisor } = yield* makeHarness(); + + const resultFiber = yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + () => Effect.succeed({}), + ).pipe( + Stream.mapEffect(([producerSession, value]) => + value === firstValue + ? Deferred.succeed(firstValueBlocked, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstValue)), + Effect.as([producerSession, value] as const), + ) + : Effect.succeed([producerSession, value] as const), + ), + Stream.take(3), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + yield* SubscriptionRef.set(activeSession, Option.some(firstSession)); + yield* Deferred.await(firstSubscribed); + yield* Deferred.await(firstValueBlocked); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + yield* Deferred.await(secondSubscribed); + yield* Deferred.succeed(releaseFirstValue, undefined); + + const result = yield* Fiber.join(resultFiber); + expect(result).toEqual([ + [firstSession, firstValue], + [firstSession, bufferedFirstValue], + [secondSession, secondValue], + ]); + }), + ); + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => Effect.gen(function* () { const subscriptions: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 0d68d2b2d531..175d633e242f 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -178,15 +178,15 @@ interface SubscriptionOptions { readonly resubscribe?: Stream.Stream; } -export function subscribeDynamic( +function subscribeDynamicMapped( tag: TTag, makeInput: (session: RpcSession) => Effect.Effect>, + mapStream: ( + session: RpcSession, + stream: Stream.Stream, EnvironmentRpcStreamFailure>, + ) => Stream.Stream>, options?: SubscriptionOptions, -): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure, - EnvironmentSupervisor -> { +): Stream.Stream, EnvironmentSupervisor> { return Stream.unwrap( Effect.gen(function* () { const supervisor = yield* EnvironmentSupervisor; @@ -216,10 +216,7 @@ export function subscribeDynamic( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; - const subscribeToSession = (): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure - > => + const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( Effect.gen(function* () { @@ -229,7 +226,7 @@ export function subscribeDynamic( method: tag, input, }); - return method(input).pipe( + return mapStream(session, method(input)).pipe( Stream.ensuring(completeObservation), Stream.catchCause((cause) => { const hasOnlyExpectedFailures = @@ -287,6 +284,36 @@ export function subscribeDynamic( ); } +export function subscribeDynamic( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped(tag, makeInput, (_session, stream) => stream, options); +} + +/** Tags each value before `switchMap` can buffer it across a session change. */ +export function subscribeDynamicWithSession( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + readonly [session: RpcSession, value: EnvironmentRpcStreamValue], + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped( + tag, + makeInput, + (session, stream) => stream.pipe(Stream.map((value) => [session, value] as const)), + options, + ); +} + export function subscribe( tag: TTag, input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 6567726a4809..878f8c902f91 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -7,8 +7,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { + applyServerWelcomeEvent, + makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, - projectServerWelcome, resolveServerConfigValue, + resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, @@ -494,25 +496,227 @@ describe("server state projection", () => { expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); }); - it("retains welcome when a ready event follows in the same stream chunk", () => { + it("keeps a current welcome on ready and rejects a buffered welcome from the old session", () => { + const firstSession = session({} as WsRpcProtocolClient); + const secondSession = session({} as WsRpcProtocolClient); const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], cwd: "/repo", projectName: "repo", } as ServerLifecycleWelcomePayload; - const [afterWelcome] = projectServerWelcome(Option.none(), { + const initial = { + currentSession: firstSession, + welcomeSession: firstSession, + welcome: null, + }; + const afterWelcome = applyServerWelcomeEvent(initial, firstSession, { type: "welcome", payload: welcome, }); - const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, { type: "ready", payload: {}, }); + const afterSwitch = { ...afterReady, currentSession: secondSession }; + const afterBufferedOldWelcome = applyServerWelcomeEvent(afterSwitch, firstSession, { + type: "welcome", + payload: { ...welcome, cwd: "/stale" }, + }); - expect(Option.getOrThrow(afterReady)).toBe(welcome); - expect(emitted).toEqual([]); + expect(afterReady).toBe(afterWelcome); + expect(resolveServerWelcomeState(afterReady)).toBe(welcome); + expect(afterBufferedOldWelcome).toBe(afterSwitch); + expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull(); }); + it.effect("checks the authoritative session before accepting a buffered welcome", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(firstEvents)), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const staleWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/stale", + projectName: "stale", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + + // Model the point after the ref changed but before either subscriber + // processed its publication. + supervisorSession.value = Option.some(secondSession); + const handled = yield* SubscriptionRef.changes(state).pipe( + Stream.filter( + (value) => value.currentSession === secondSession || value.welcome === staleWelcome, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: staleWelcome }); + + const next = yield* Fiber.join(handled); + expect(next.currentSession).toBe(secondSession); + expect(resolveServerWelcomeState(next)).toBeNull(); + }), + ); + }), + ); + + it.effect("reads the authoritative session after waiting for the welcome state lock", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(Stream.drain), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const thirdSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + const changed = yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => value.currentSession !== firstSession), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + + yield* state.semaphore.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + supervisorSession.value = Option.some(thirdSession); + }), + ); + + expect((yield* Fiber.join(changed)).currentSession).toBe(thirdSession); + }), + ); + }), + ); + + it.effect("clears a welcome until the reconnected session sends its own", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const secondEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const firstWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/first", + projectName: "first", + } as ServerLifecycleWelcomePayload; + const secondWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/second", + projectName: "second", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const nextResolved = ( + predicate: (value: ServerLifecycleWelcomePayload | null) => boolean, + ) => + SubscriptionRef.changes(state).pipe( + Stream.map(resolveServerWelcomeState), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const first = yield* nextResolved((value) => value === firstWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: firstWelcome }); + expect(yield* Fiber.join(first)).toBe(firstWelcome); + + const cleared = yield* nextResolved((value) => value === null).pipe(Effect.forkChild); + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + expect(yield* Fiber.join(cleared)).toBeNull(); + expect(resolveServerWelcomeState(yield* SubscriptionRef.get(state))).toBeNull(); + + const second = yield* nextResolved((value) => value === secondWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(secondEvents, { type: "welcome", payload: secondWelcome }); + expect(yield* Fiber.join(second)).toBe(secondWelcome); + }), + ); + }), + ); + it("prefers an active session config over cache until a live event arrives", () => { const config = (source: string, serverVersion: string) => ({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7ba62a681481..9f94663388ec 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -40,8 +40,10 @@ import { request, runStream, subscribe, + subscribeDynamicWithSession, type EnvironmentRpcInput, } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { applyServerConfigProjection, @@ -476,21 +478,119 @@ export function serverConfigStateChanges( ); } -export function projectServerWelcome( - current: Option.Option, +export function applyServerWelcomeEvent( + current: EnvironmentServerWelcomeState, + session: RpcSession, event: { readonly type: "welcome" | "ready"; readonly payload: unknown; }, -): readonly [ - Option.Option, - ReadonlyArray, -] { - if (event.type !== "welcome") { - return [current, []]; - } - const welcome = event.payload as ServerLifecycleWelcomePayload; - return [Option.some(welcome), [welcome]]; +): EnvironmentServerWelcomeState { + return event.type === "welcome" && current.currentSession === session + ? { + ...current, + welcomeSession: session, + welcome: event.payload as ServerLifecycleWelcomePayload, + } + : current; +} + +export interface EnvironmentServerWelcomeState { + readonly currentSession: RpcSession | null; + readonly welcomeSession: RpcSession | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +export function resolveServerWelcomeState( + state: EnvironmentServerWelcomeState, +): ServerLifecycleWelcomePayload | null { + return state.currentSession === state.welcomeSession ? state.welcome : null; +} + +export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWelcomeState.make")( + function* () { + const supervisor = yield* EnvironmentSupervisor; + const initialSession = Option.getOrNull(yield* SubscriptionRef.get(supervisor.session)); + const state = yield* SubscriptionRef.make({ + currentSession: initialSession, + welcomeSession: null, + welcome: null, + }); + + const updateWithCurrentSession = Effect.fn( + "EnvironmentServerWelcomeState.updateWithCurrentSession", + )(function* ( + update: ( + current: EnvironmentServerWelcomeState, + currentSession: RpcSession | null, + ) => EnvironmentServerWelcomeState, + ) { + return yield* SubscriptionRef.modifyEffect(state, (current) => + SubscriptionRef.get(supervisor.session).pipe( + Effect.map( + (latestSession) => + [undefined, update(current, Option.getOrNull(latestSession))] as const, + ), + ), + ); + }); + + yield* SubscriptionRef.changes(supervisor.session).pipe( + Stream.runForEach(() => + updateWithCurrentSession((current, currentSession) => ({ + ...current, + currentSession, + })), + ), + Effect.forkScoped, + ); + + yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + Effect.fn("EnvironmentServerWelcomeState.makeSubscribeInput")(function* (session) { + yield* updateWithCurrentSession((current, currentSession) => + currentSession === session + ? { + ...current, + currentSession, + welcomeSession: session, + welcome: null, + } + : { ...current, currentSession }, + ); + return {}; + }), + ).pipe( + Stream.runForEach(([session, event]) => + updateWithCurrentSession((current, currentSession) => + applyServerWelcomeEvent( + { + ...current, + currentSession, + }, + session, + event, + ), + ), + ), + Effect.forkScoped, + ); + + return state; + }, +); + +export function serverWelcomeStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeEnvironmentServerWelcomeState().pipe( + Effect.map((state) => + SubscriptionRef.changes(state).pipe(Stream.map(resolveServerWelcomeState)), + ), + ), + ), + ); } export function resolveServerConfigValue( @@ -833,6 +933,27 @@ export function createServerEnvironmentAtoms( Atom.withLabel(`environment-data:server:providers:${environmentId}`), ), ); + const welcomeStateFamily = Atom.family((environmentId: EnvironmentId) => + runtime + .atom(serverWelcomeStateChanges(environmentId), { initialValue: null }) + .pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-data:server:welcome-state:${environmentId}`), + ), + ); + const welcomeFamily = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const result = get(welcomeStateFamily(environmentId)); + if (result._tag !== "Success") return result; + return result.value === null + ? AsyncResult.initial(result.waiting) + : AsyncResult.success(result.value, result); + }).pipe(Atom.withLabel(`environment-data:server:welcome:${environmentId}`)), + ); + const welcome = (target: { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; + }) => welcomeFamily(target.environmentId); return { configValueAtom, @@ -916,14 +1037,7 @@ export function createServerEnvironmentAtoms( refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), }), configProjection, - welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { - label: "environment-data:server:welcome", - tag: WS_METHODS.subscribeServerLifecycle, - transform: (stream) => - stream.pipe( - Stream.mapAccum(Option.none, projectServerWelcome), - ), - }), + welcome, consumeResetCredit: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:consume-reset-credit", tag: WS_METHODS.providerConsumeResetCredit, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 9a9be9b4da09..1f52eccc94cd 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -380,6 +380,40 @@ describe("applyThreadDetailEvent", () => { } }); + it("keeps imported replies turnless when delivered again", () => { + const event = { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.message-sent", + payload: { + threadId: baseThread.id, + messageId: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported reply", + turnId: null, + streaming: false, + createdAt: "2026-03-01T06:00:00.000Z", + updatedAt: "2026-03-01T06:00:00.000Z", + }, + } as const; + + const imported = applyThreadDetailEvent(baseThread, event); + expect(imported.kind).toBe("updated"); + if (imported.kind !== "updated") return; + expect(imported.thread.latestTurn).toBeNull(); + expect(imported.thread.checkpoints).toBe(baseThread.checkpoints); + + const repeated = applyThreadDetailEvent(imported.thread, { ...event, sequence: 7 }); + expect(repeated.kind).toBe("updated"); + if (repeated.kind !== "updated") return; + expect(repeated.thread.messages).toEqual(imported.thread.messages); + expect(repeated.thread.latestTurn).toBeNull(); + expect(repeated.thread.checkpoints).toBe(baseThread.checkpoints); + }); + it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, @@ -1178,6 +1212,100 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.reverted", () => { + it("keeps imported history and removes the first live prompt at checkpoint zero", () => { + const threadWithImportedHistory: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("import:codex:session-1:000000"), + role: "user", + text: "Imported prompt", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }, + { + id: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported answer", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:01:00.000Z", + updatedAt: "2026-03-01T00:01:00.000Z", + }, + { + id: MessageId.make("live-user-message"), + role: "user", + text: "New work", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithImportedHistory, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T02:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.text)).toEqual([ + "Imported prompt", + "Imported answer", + ]); + } + }); + + it("fallback-retains the earliest absolute timestamp across offsets", () => { + const threadWithOffsetMessages: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("earlier-by-offset"), + role: "user", + text: "Earlier", + turnId: null, + streaming: false, + createdAt: "2026-04-01T10:30:00.000+02:00", + updatedAt: "2026-04-01T10:30:00.000+02:00", + }, + { + id: MessageId.make("later-in-utc"), + role: "user", + text: "Later", + turnId: null, + streaming: false, + createdAt: "2026-04-01T09:00:00.000Z", + updatedAt: "2026-04-01T09:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithOffsetMessages, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T10:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 1 }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.id)).toEqual(["earlier-by-offset"]); + } + }); + it("filters entities to retained turns", () => { const threadWithData: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 1de0b654c060..c237856f90b1 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,6 +12,8 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; +import { isImportedAgentSessionMessageId } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -551,7 +553,11 @@ export function applyThreadDetailEvent( ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); - const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); + const messages = retainMessagesAfterRevert( + thread.messages, + retainedTurnIds, + event.payload.turnCount, + ); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), @@ -744,16 +750,42 @@ function rebindCheckpointAssistantMessage( function retainMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, + turnCount: number, ): OrchestrationMessage[] { - // Keep messages that belong to a retained turn, plus system messages and - // messages without a turn binding (pre-turn-0 user messages). - return Arr.filter(messages, (message) => { - if (message.role === "system") { - return true; + const retainedMessageIds = new Set(); + for (const message of messages) { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { + retainedMessageIds.add(message.id); + } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { + retainedMessageIds.add(message.id); } - if (message.turnId === null) { - return true; + } + + for (const role of ["user", "assistant"] as const) { + const retainedCount = messages.filter( + (message) => + message.role === role && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), + ).length; + const missingCount = Math.max(0, turnCount - retainedCount); + const fallbackMessages = messages + .filter( + (message) => + message.role === role && + !retainedMessageIds.has(message.id) && + (message.turnId === null || retainedTurnIds.has(message.turnId)), + ) + .toSorted( + (left, right) => + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), + ) + .slice(0, missingCount); + for (const message of fallbackMessages) { + retainedMessageIds.add(message.id); } - return retainedTurnIds.has(message.turnId); - }); + } + + return Arr.filter(messages, (message) => retainedMessageIds.has(message.id)); } diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts new file mode 100644 index 000000000000..ffd90dd79db8 --- /dev/null +++ b/packages/contracts/src/agentSessions.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** Coding agent home directories the scanner knows how to read. */ +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export type AgentSessionSource = typeof AgentSessionSource.Type; + +/** File identity saved with an imported session so bounded retries can skip unchanged history. */ +export const AgentSessionImportSource = Schema.Struct({ + provider: AgentSessionSource, + providerInstanceId: ProviderInstanceId, + providerSessionId: TrimmedNonEmptyString, + filePath: TrimmedNonEmptyString, + size: NonNegativeInt, + mtimeMs: Schema.NullOr(Schema.Number), + device: Schema.Number, + inode: Schema.NullOr(Schema.Number), + birthtimeMs: Schema.NullOr(Schema.Number), +}); +export type AgentSessionImportSource = typeof AgentSessionImportSource.Type; + +/** Imported message ids retain their origin after event metadata is projected into SQLite. */ +export function isImportedAgentSessionMessageId(messageId: string): boolean { + return messageId.startsWith("import:"); +} + +/** + * Empty for now. Kept as a struct so future scan options (source filters, + * explicit roots) can be added without a new method. + */ +export const AgentSessionScanInput = Schema.Struct({}); +export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; + +/** + * A directory that at least one agent CLI has run in, suitable for import as a + * T3 Code project. `alreadyImported` marks candidates that already have an + * active project rooted at the same path. + */ +export const AgentSessionProjectCandidate = Schema.Struct({ + path: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + projectId: Schema.optional(ProjectId), + sources: Schema.Array(AgentSessionSource), + threadCount: NonNegativeInt, + lastActiveAt: Schema.NullOr(IsoDateTime), + alreadyImported: Schema.Boolean, +}); +export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; + +export const AgentSessionScanResult = Schema.Struct({ + candidates: Schema.Array(AgentSessionProjectCandidate), + scannedAt: IsoDateTime, + truncated: Schema.optional(Schema.Boolean), +}); +export type AgentSessionScanResult = typeof AgentSessionScanResult.Type; + +export const AgentSessionImportInput = Schema.Struct({ + projectId: ProjectId, + expectedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), +}); +export type AgentSessionImportInput = typeof AgentSessionImportInput.Type; + +export class AgentSessionImportProjectNotFoundError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectNotFoundError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' does not exist.`; + } +} + +export class AgentSessionImportProjectChangedError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectChangedError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' changed directories. Scan for projects again before importing history.`; + } +} + +export const AgentSessionImportResult = Schema.Struct({ + importedCount: NonNegativeInt, + skippedCount: NonNegativeInt, +}); +export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; + +export class AgentSessionScanError extends Schema.TaggedErrorClass()( + "AgentSessionScanError", + { + operation: Schema.Literals(["read-settings", "read-projects"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to scan agent sessions during ${this.operation}.`; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9d7cfd30d628..74a1b4939f1a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -29,6 +29,7 @@ export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./agentSessions.ts"; export * from "./assets.ts"; export * from "./review.ts"; export * from "./browserImport.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d7e33ebb713b..11c21deecddb 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1135,6 +1135,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("rejects thread history imports without messages", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.history.import", + commandId: "command-empty-history", + threadId: "thread-1", + messages: [], + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17cadc6d1d7f..92e9fe01dd42 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -800,6 +800,7 @@ const ThreadCreateCommand = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, + historyImport: Schema.optional(Schema.Literal(true)), }); const ThreadDeleteCommand = Schema.Struct({ @@ -1122,6 +1123,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadHistoryImportCommand = Schema.Struct({ + type: Schema.Literal("thread.history.import"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array( + Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, + }), + ).check(Schema.isNonEmpty()), +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -1173,6 +1188,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadHistoryImportCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -1473,6 +1489,7 @@ export const OrchestrationEventMetadata = Schema.Struct({ adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), ingestedAt: Schema.optional(IsoDateTime), + historyImport: Schema.optional(Schema.Boolean), origin: Schema.optional(OrchestrationClientOrigin), }); export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f7f2c2b6faa7..4e8ae2e54134 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -28,6 +28,15 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { + AgentSessionImportInput, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionImportResult, + AgentSessionScanInput, + AgentSessionScanResult, + AgentSessionScanError, +} from "./agentSessions.ts"; import { AssetAccessError, AssetCreateUrlInput, @@ -240,6 +249,8 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + agentSessionsScan: "agentSessions.scan", + agentSessionsImport: "agentSessions.import", assetsCreateUrl: "assets.createUrl", attachmentsCreateUploadUrl: "attachments.createUploadUrl", attachmentsDelete: "attachments.delete", @@ -823,6 +834,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { + payload: AgentSessionScanInput, + success: AgentSessionScanResult, + error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), +}); + +export const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { + payload: AgentSessionImportInput, + success: AgentSessionImportResult, + error: Schema.Union([ + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionScanError, + EnvironmentAuthorizationError, + ]), +}); + export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, @@ -1241,6 +1269,8 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAgentSessionsScanRpc, + WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, WsAttachmentsCreateUploadUrlRpc, WsAttachmentsDeleteRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ba0d6679cfc2..3ea7bed8f1c4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, + bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])), bootstrapProjectId: Schema.optional(ProjectId), bootstrapThreadId: Schema.optional(ThreadId), + bootstrapProjectCreated: Schema.optional(Schema.Boolean), + bootstrapThreadCreated: Schema.optional(Schema.Boolean), }); export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 50923423352b..623780c1fb8b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -286,6 +286,13 @@ export const ClientSettingsSchema = Schema.Struct({ // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // When the first-run welcome wizard finished (or was skipped), as an ISO + // timestamp. `null` alone does not mean "show the wizard" — every install + // that predates this field decodes to `null` — so the gate also requires an + // empty workspace before it treats the client as a fresh install. + onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -1188,6 +1195,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), fontSizeCode: Schema.optionalKey(CodeFontSize), diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed4923888..066253602a49 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -7,12 +7,18 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalError, TerminalOpenInput, + TerminalProviderEnvironmentError, TerminalResizeInput, TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, } from "./terminal.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const encodeTerminalError = Schema.encodeUnknownSync(TerminalError); +const decodeTerminalError = Schema.decodeUnknownSync(TerminalError); function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; @@ -27,6 +33,28 @@ function decodes(schema: S, input: unknown): boolean { } } +describe("TerminalProviderEnvironmentError", () => { + it("round-trips its required cause without exposing it in the message", () => { + const cause = { operation: "read-secret", detail: "secret backend unavailable" }; + const error = new TerminalProviderEnvironmentError({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + cause, + }); + const encoded = encodeTerminalError(error); + const decoded = decodeTerminalError(encoded); + + expect(decoded).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId: "codex_work", + cause, + }); + expect(decoded.message).toBe( + "Could not prepare the terminal environment for provider instance: codex_work", + ); + expect(decoded.message).not.toContain("secret backend unavailable"); + }); +}); + describe("TerminalOpenInput", () => { it("accepts valid open input", () => { expect( @@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => { T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }, + providerInstanceId: "codex_work", }); expect(parsed.env).toMatchObject({ T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }); expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); + expect(parsed.providerInstanceId).toBe("codex_work"); }); it("rejects invalid env keys", () => { @@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => { }), ).toBe(false); }); + + it("rejects invalid provider instance ids", () => { + for (const providerInstanceId of ["", "1invalid", "invalid id"]) { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project", + providerInstanceId, + }), + ).toBe(false); + } + }); }); describe("TerminalAttachInput", () => { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index fa5f18211695..36e3d339f521 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalOpenInput = Schema.Codec.Encoded; +export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), restartIfNotRunning: Schema.optional(Schema.Boolean), }); -export type TerminalAttachInput = Schema.Codec.Encoded; +export type TerminalAttachInput = typeof TerminalAttachInput.Type; export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({ cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalRestartInput = Schema.Codec.Encoded; +export type TerminalRestartInput = typeof TerminalRestartInput.Type; export const TerminalCloseInput = Schema.Struct({ ...TerminalThreadInput.fields, @@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass()( + "TerminalProviderInstanceNotFoundError", + { + providerInstanceId: ProviderInstanceId, + }, +) { + override get message() { + return `Provider instance is not available: ${this.providerInstanceId}`; + } +} + +export class TerminalProviderEnvironmentError extends Schema.TaggedErrorClass()( + "TerminalProviderEnvironmentError", + { + providerInstanceId: ProviderInstanceId, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`; + } +} + export class TerminalNotRunningError extends Schema.TaggedErrorClass()( "TerminalNotRunningError", { @@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalNotRunningError, TerminalWriteError, TerminalResizeError, diff --git a/packages/shared/package.json b/packages/shared/package.json index fd932b8b146b..d6915120b806 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -270,6 +270,10 @@ "./hostClassification": { "types": "./src/hostClassification.ts", "import": "./src/hostClassification.ts" + }, + "./dateTime": { + "types": "./src/dateTime.ts", + "import": "./src/dateTime.ts" } }, "scripts": { diff --git a/packages/shared/src/dateTime.test.ts b/packages/shared/src/dateTime.test.ts new file mode 100644 index 000000000000..562507de3ac2 --- /dev/null +++ b/packages/shared/src/dateTime.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { compareDateTimeStrings } from "./dateTime.ts"; + +describe("compareDateTimeStrings", () => { + it("compares valid date-time strings by absolute time", () => { + expect( + compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"), + ).toBe(0); + expect( + compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"), + ).toBeGreaterThan(0); + }); + + it.each([ + ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"], + ["2000-02-29T00:00:00.100Z", "2000-02-28T20:30:00.1-03:30"], + ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"], + ["+010000-01-01T00:00:00.000Z", "9999-12-31T23:00:00.000-01:00"], + ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00:00.0000Z", "2026-09-02T00:00:00.000Z"], + ["2024-02-29T24:00:00+05:30", "2024-03-01T00:00:00+05:30"], + ["2026-12-31T24:00:00-07:00", "2027-01-01T07:00:00Z"], + ["2026-09-01T12:00Z", "2026-09-01T12:00:00.000Z"], + ["2026-09-01T05:00-07:00", "2026-09-01T12:00:00Z"], + ["2026-09-01T24:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00+05:30", "2026-09-02T00:00:00+05:30"], + ])("preserves equal ISO instants %s and %s", (left, right) => { + expect(compareDateTimeStrings(left, right)).toBe(0); + }); + + it("sorts malformed values before valid values", () => { + expect(compareDateTimeStrings("invalid", "2026-09-01T12:00:00.000Z")).toBeLessThan(0); + expect(compareDateTimeStrings("2026-09-01T12:00:00.000Z", "invalid")).toBeGreaterThan(0); + }); + + it.each([ + "2014-02-30", + "2014-03-02", + "2014-03-02T00:00:00", + "2014-03-02T00:00:00.000", + "03/02/2014", + "March 2, 2014", + "Sun, 02 Mar 2014 00:00:00 GMT", + "2014-03-02T00:00:00.000Z\n", + "2014-02-30T00:00:00.000Z", + "1900-02-29T00:00:00.000-07:00", + "2024-04-31T00:00:00.000+05:30", + "2024-03-02T12:00:00.000+24:00", + "2026-09-01T24:01:00Z", + "2026-09-01T24:00:01Z", + "2026-09-01T24:00:00.0001Z", + "2026-09-01T24:01Z", + "2026-09-01T25:00Z", + ])("treats %s as malformed without native date guessing", (malformed) => { + const valid = "1970-01-01T00:00:00.000Z"; + expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0); + expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0); + expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, malformed)).toBe(0); + }); + + it("uses code-unit order for malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid-a", "invalid-B")).toBeGreaterThan(0); + expect(compareDateTimeStrings("invalid-B", "invalid-a")).toBeLessThan(0); + }); + + it("returns zero for equal malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid", "invalid")).toBe(0); + }); + + it("gives every permutation of mixed values the same order", () => { + const early = "2026-09-01T12:00:00.000+14:00"; + const late = "2026-09-01T00:00:00.000-12:00"; + const malformed = "2026-09-01T06:invalid"; + const expected = [malformed, early, late]; + + const permutations = [ + [early, late, malformed], + [early, malformed, late], + [late, early, malformed], + [late, malformed, early], + [malformed, early, late], + [malformed, late, early], + ]; + + for (const values of permutations) { + expect(values.toSorted(compareDateTimeStrings)).toEqual(expected); + } + }); +}); diff --git a/packages/shared/src/dateTime.ts b/packages/shared/src/dateTime.ts new file mode 100644 index 000000000000..544dd2d0d73b --- /dev/null +++ b/packages/shared/src/dateTime.ts @@ -0,0 +1,38 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const isZonedIsoDateTime = Schema.is( + Schema.String.check( + Schema.isPattern( + /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, + ), + Schema.isTrimmed(), + ), +); + +function parseTimestamp(value: string): number { + if (!isZonedIsoDateTime(value)) return Number.NaN; + + // Engines can normalize invalid calendar dates instead of rejecting them. + const datePart = value.slice(0, value.indexOf("T")); + const date = DateTime.make(`${datePart}T00:00:00.000Z`); + if (Option.isNone(date)) return Number.NaN; + const parts = DateTime.toPartsUtc(date.value); + if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { + return Number.NaN; + } + return Date.parse(value); +} + +/** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ +export function compareDateTimeStrings(left: string, right: string): number { + const leftTimestamp = parseTimestamp(left); + const rightTimestamp = parseTimestamp(right); + const leftIsValid = !Number.isNaN(leftTimestamp); + const rightIsValid = !Number.isNaN(rightTimestamp); + + if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; + if (leftIsValid) return leftTimestamp - rightTimestamp; + return left < right ? -1 : left > right ? 1 : 0; +} From 2271a27dad1205b403a66af01461f856986e5064 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 03:07:18 -0700 Subject: [PATCH 098/103] fix(server): keep Homebrew mise shims manual-only (#10085) --- .../src/provider/Drivers/CodexDriver.test.ts | 151 ++++++++++++++++++ .../src/provider/providerMaintenance.test.ts | 43 +++-- .../src/provider/providerMaintenance.ts | 4 + 3 files changed, 184 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 7e2f2f8864b6..bac34db452fd 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -8,7 +8,10 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -17,6 +20,11 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; +import { + createProviderVersionAdvisory, + ProviderVersionCache, + resolveLatestProviderVersion, +} from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { @@ -218,4 +226,147 @@ it.layer(testLayer)("CodexDriver", (it) => { ), ); } + + it.effect.each([ + { + name: "conventional shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "custom data directory", + dataRoot: "custom-tool-data", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "renamed configured command", + dataRoot: "mise", + commandName: "custom-codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "outdated provider", + dataRoot: "mise", + commandName: "codex", + version: "0.153.3", + nodeFirst: false, + }, + { + name: "npm before shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: true, + }, + ])( + "does not mistake Homebrew mise for Codex's installer: $name", + (fixture) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const brewPrefix = NodePath.join(tempDir, "homebrew"); + const brewPath = NodePath.join(brewPrefix, "bin", "brew"); + const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); + const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); + const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); + const npmBin = NodePath.join(npmPrefix, "bin"); + const npmEntry = NodePath.join( + npmPrefix, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + for (const file of [brewPath, misePath, npmEntry]) { + yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "#!/bin/sh\n"); + yield* fs.chmod(file, 0o755); + } + yield* fs.makeDirectory(shimDir, { recursive: true }); + yield* fs.makeDirectory(npmBin, { recursive: true }); + yield* fs.symlink(misePath, NodePath.join(shimDir, fixture.commandName)); + yield* fs.symlink(npmEntry, NodePath.join(npmBin, fixture.commandName)); + const lookupPath = [ + ...(fixture.nodeFirst ? [npmBin, shimDir] : [shimDir, npmBin]), + NodePath.dirname(brewPath), + ].join(NodePath.delimiter); + const probes: Array> = []; + const metadataSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + return Effect.die("Provider resolution must not execute a provider or updater"); + } + probes.push(command.args); + const stdout = + command.args[0] === "--prefix" + ? brewPrefix + : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-mise-shim"), + displayName: "Codex shim test", + enabled: false, + environment: [{ name: "PATH", value: lookupPath, sensitive: false }], + config: { + ...CodexDriver.defaultConfig(), + binaryPath: fixture.commandName, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); + const capabilities = yield* instance.snapshot.resolveMaintenance(); + const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + ["@openai/codex", { expiresAt: Number.MAX_SAFE_INTEGER, version: "0.153.4" }], + ]), + ), + ); + expect(probes).toEqual([]); + expect(latestVersion).toBe("0.153.4"); + expect( + createProviderVersionAdvisory({ + driver: CodexDriver.driverKind, + currentVersion: fixture.version, + latestVersion, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ + status: fixture.version === "0.153.4" ? "current" : "behind_latest", + currentVersion: fixture.version, + latestVersion: "0.153.4", + canUpdate: fixture.nodeFirst, + }); + if (fixture.nodeFirst) { + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + }); + } else { + expect(capabilities.update).toBeNull(); + } + }).pipe(Effect.scoped), + { skip: windowsHost }, + ); }); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 94fc376f227d..2ceaf21996bf 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -595,25 +595,29 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect.skipIf(!symlinksSupported)( - "upgrades the Homebrew cask that owns the binary and compares against its version", - () => + it.effect.each([ + { directory: "Caskroom", name: "package-tool", kind: "cask" }, + { directory: "Cellar", name: "package-tool", kind: "formula" }, + { directory: "Cellar", name: "package-tool@latest", kind: "formula" }, + ] as const)( + "upgrades the owning Homebrew $kind $name through an executable alias", + (fixture) => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-homebrew-capabilities"); const brewBinDir = NodePath.join(tempDir, "brew-bin"); const brewPath = NodePath.join(brewBinDir, "brew"); writeExecutable(brewPath); - const caskBinary = NodePath.join( + const ownedBinary = NodePath.join( tempDir, - "Caskroom", - "package-tool", + fixture.directory, + fixture.name, "0.148.0", - "package-tool", + "package-tool-0.148.0", ); - writeExecutable(caskBinary); - const link = NodePath.join(tempDir, "bin", "package-tool"); + writeExecutable(ownedBinary); + const link = NodePath.join(tempDir, "bin", "custom-package-tool"); NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); - NodeFS.symlinkSync(caskBinary, link); + NodeFS.symlinkSync(ownedBinary, link); const spawned: Array> = []; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( @@ -630,27 +634,38 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { spawned.push([command, ...args]); return args[0] === "--prefix" ? `${tempDir}\n` - : JSON.stringify({ casks: [{ version: "0.148.0,42" }] }); + : JSON.stringify( + fixture.kind === "cask" + ? { casks: [{ version: "0.148.0,42" }] } + : { formulae: [{ versions: { stable: "0.148.0" } }] }, + ); }), ), ); expect(spawned).toEqual([ [brewPath, "--prefix"], - [brewPath, "info", "--json=v2", "package-tool"], + [brewPath, "info", "--json=v2", fixture.name], ]); expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", latestVersion: "0.148.0", update: { - command: "brew upgrade --cask package-tool", + command: + fixture.kind === "cask" + ? `brew upgrade --cask ${fixture.name}` + : `brew upgrade ${fixture.name}`, executable: brewPath, - args: ["upgrade", "--cask", "package-tool"], + args: + fixture.kind === "cask" + ? ["upgrade", "--cask", fixture.name] + : ["upgrade", fixture.name], lockKey: "homebrew", }, }); }), + { skip: !symlinksSupported }, ); it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index d812f1ab7989..e8ff090a4ec9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -432,6 +432,10 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { + // Mise shims resolve to the version manager, not the provider. + if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { + return manual; + } const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), ); From 39802c06117fae0b3da43624b0d54309c5437c72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 03:42:49 -0700 Subject: [PATCH 099/103] fix(ssh): report remote package installation failures accurately (#10088) --- packages/ssh/src/runnerProcess.test.ts | 140 +++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 5 +- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 7dda675ee95c..d89ee5582c35 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -165,3 +165,143 @@ if (args.includes("--package")) { ); }, ); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner install diagnostics", + () => { + const decodeArguments = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(Schema.String)), + ); + const cases = (["npx", "npm"] as const).flatMap((packageManager) => + ( + [ + "etarget", + "network", + "empty-success", + "success", + "failed-with-path", + "existing-cli", + "node-override", + ] as const + ).map((mode) => ({ packageManager, mode })), + ); + + it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "installer-calls.jsonl"); + const packageSpec = "t3@0.0.39-nightly.20260905.1286"; + const args = ["serve", "a path with spaces"]; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString(callsPath, ""); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); +const mode = process.env.T3_TEST_MODE; +if (mode === "success" || mode === "failed-with-path") { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} +if (mode === "etarget" || mode === "failed-with-path") { + process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); + process.exitCode = 42; +} else if (mode === "network") { + process.stderr.write("npm error code ENETUNREACH\\n"); + process.exitCode = 43; +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); + + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", ...args], { + cwd: fixture, + extendEnv: false, + env: { + PATH: bin, + T3_TEST_MODE: mode, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + }, + stdin: Stream.make( + new TextEncoder().encode( + buildRemoteT3RunnerScript({ + packageSpec, + ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), + }), + ), + ), + }), + ); + const { stdout, stderr, exitCode } = yield* Effect.all( + { + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode, + }, + { concurrency: "unbounded" }, + ); + const installFailed = + mode === "etarget" || mode === "network" || mode === "failed-with-path"; + const missingExecutable = mode === "empty-success"; + assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); + if (installFailed || missingExecutable) { + assert.equal(stdout, ""); + } else { + assert.deepEqual(decodeArguments(stdout), args); + } + if (installFailed) { + const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; + assert.include(stderr, `npm error code ${npmError}\n`); + assert.include(stderr, `Remote host could not install ${packageSpec}.`); + assert.notInclude(stderr, "Remote host installed"); + assert.notInclude(stderr, "Install a C toolchain"); + } else if (missingExecutable) { + assert.include(stderr, `Remote host installed ${packageSpec}`); + assert.include(stderr, "npm produced no t3 executable"); + assert.include(stderr, "Install a C toolchain"); + } else { + assert.equal(stderr, ""); + } + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; + const calls = yield* fs.readFileString(callsPath); + if (usesInstaller) { + assert.deepEqual( + calls + .trim() + .split("\n") + .map((line) => decodeArguments(line)), + [expectedCall], + ); + } else { + assert.equal(calls, ""); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 04dbce65af60..409fd5c7688c 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -433,7 +433,10 @@ fi # never becomes ready. Resolve the CLI once up front so that install failure is # reported here, with npm's own output on stderr. require_installed_t3_cli() { - T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then + printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 + fi if [ -n "$T3_CLI_PATH" ]; then return 0 fi From 17bc8fba030e2605d53c16190a4e1dd2523abe60 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:10:37 +0200 Subject: [PATCH 100/103] fix: restore fork APIs after knip unused-export sync Knip on upstream deleted helpers the fork still uses (formatElapsed, removePreviewThread, haveProvidersChanged, welcome model selection). Restore those exports, HashMap thread lookups, and projection mocks so typecheck passes after the 39802c0611 merge. Made with grok-4.6 --- apps/desktop/src/shell/DesktopOpenWith.ts | 8 ++++-- .../src/orchestration/decider.import.test.ts | 10 +++++-- .../src/project/AgentSessionImporter.test.ts | 6 ++++- .../src/project/AgentSessionScanner.test.ts | 3 +++ .../ProjectLifecycleScriptRunner.test.ts | 1 + .../src/provider/Layers/ProviderRegistry.ts | 2 +- apps/server/src/server.test.ts | 5 +--- apps/server/src/serverRuntimeStartup.test.ts | 3 +++ apps/server/src/serverRuntimeStartup.ts | 2 +- .../web/src/components/ChatView.logic.test.ts | 27 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 1 - .../src/components/preview/PreviewView.tsx | 2 +- apps/web/src/previewStateStore.ts | 7 +++++ .../shared/src/orchestrationTiming.test.ts | 8 +++++- packages/shared/src/orchestrationTiming.ts | 10 +++++++ 15 files changed, 81 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/shell/DesktopOpenWith.ts b/apps/desktop/src/shell/DesktopOpenWith.ts index 6fc855971b7c..95da0123394e 100644 --- a/apps/desktop/src/shell/DesktopOpenWith.ts +++ b/apps/desktop/src/shell/DesktopOpenWith.ts @@ -198,8 +198,12 @@ export const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); + const readClientSettings = clientSettings.get.pipe( + Effect.catchTag("DesktopClientSettingsReadError", () => Effect.succeed(Option.none())), + ); + const resolvePresentations = Effect.gen(function* () { - const settings = yield* clientSettings.get; + const settings = yield* readClientSettings; if (Option.isNone(settings)) return []; return yield* Effect.forEach(settings.value.openWithEntries, (entry) => Effect.gen(function* () { @@ -245,7 +249,7 @@ export const make = Effect.gen(function* () { reason: "not-directory", }); } - const settings = yield* clientSettings.get; + const settings = yield* readClientSettings; const entry = Option.isSome(settings) ? settings.value.openWithEntries.find((candidate) => candidate.id === input.entryId) : undefined; diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts index c809c733800a..36887fce8415 100644 --- a/apps/server/src/orchestration/decider.import.test.ts +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -9,10 +9,16 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; import * as TestClock from "effect/testing/TestClock"; import { decideOrchestrationCommand } from "./decider.ts"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; +import type { CommandReadModel } from "./commandReadModel.ts"; + +function firstThread(model: CommandReadModel) { + return Array.from(HashMap.values(model.threads))[0]; +} it.layer(NodeServices.layer)("thread history import", (it) => { it.effect("marks imported thread creation without changing live creation", () => @@ -167,7 +173,7 @@ it.layer(NodeServices.layer)("thread history import", (it) => { metadata: {}, payload: { threadId, turnCount: 0 }, }); - expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([ + expect(firstThread(projected)?.messages.map((message) => message.text)).toEqual([ "Fix the bug", "Fixed", ]); @@ -312,7 +318,7 @@ it.layer(NodeServices.layer)("thread history import", (it) => { expect(error._tag).toBe("OrchestrationCommandInvariantError"); expect(error.message).toContain("must be active and empty"); - expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt); + expect(firstThread(readModel)?.updatedAt).toBe(liveMessageAt); }), ); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 38d6ad1d4331..81413f238f32 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -54,6 +54,7 @@ import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionD import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; import { ServerSettingsService } from "../serverSettings.ts"; +import * as IdentityService from "../identity/IdentityService.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; @@ -159,6 +160,8 @@ const makeProjectedThread = (input: { : []), ] : [], + queuedMessages: [], + pendingTurnStart: null, proposedPlans: [], activities: [], checkpoints: [], @@ -574,7 +577,7 @@ const integrationLayer = Layer.mergeAll( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(integrationServerConfig), Layer.provideMerge(NodeServices.layer), ); @@ -931,6 +934,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), Layer.provide(Layer.mock(TextGeneration)({})), Layer.provide(ServerSettingsService.layerTest()), + Layer.provide(IdentityService.layerWithPeople([])), ); yield* engine.dispatch({ diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index dc6d72a0ce63..578f73222329 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -61,6 +61,9 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }); /** diff --git a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts index 3db3ad293fe5..ee941cb48c43 100644 --- a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts +++ b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts @@ -92,6 +92,7 @@ const makeProjectionSnapshotQueryLayer = ( getProjectShellById: (projectId) => Effect.succeed(project && projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 94dde9f931bb..2fd4278f3c57 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -188,7 +188,7 @@ export const mergeProviderSnapshot = ( : {}), }; -const haveProvidersChanged = ( +export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 66b4c251b462..0f99c14080a9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -49,10 +49,7 @@ import { computeDpopJwkThumbprint, type DpopPublicJwk, } from "@t3tools/shared/dpop"; -import { - appendOmegentT3ProductHandshake, - OMEGENT_T3_CLIENT_REQUIRED_MESSAGE, -} from "@t3tools/shared/productFamily"; +import { appendOmegentT3ProductHandshake } from "@t3tools/shared/productFamily"; import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt"; import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index f11bc7ef3117..41dc2c2bd44f 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -366,11 +366,14 @@ it.effect( getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getSessionStopContextById: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), + getThreadActivitiesPage: () => Effect.die("unused"), + getThreadLifecycleById: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7586d981ba00..3e4de57a3d55 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -182,7 +182,7 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 01ac0d6a53de..93f15d1ba0ba 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1569,6 +1569,33 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { }); }); +describe("startNewThreadForProject", () => { + it("starts a thread through the supplied shared handler for the active project", () => { + const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; + const projectRef = { environmentId, projectId }; + + expect( + startNewThreadForProject(projectRef, (nextProjectRef) => { + calls.push(nextProjectRef); + return Promise.resolve(); + }), + ).toBe(true); + expect(calls).toEqual([projectRef]); + }); + + it("does nothing when the active project is unavailable", () => { + let called = false; + + expect( + startNewThreadForProject(null, () => { + called = true; + return Promise.resolve(); + }), + ).toBe(false); + expect(called).toBe(false); + }); +}); + describe("server thread liveness", () => { it("requires shell and detail before treating a server thread as active", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 43a33d18252b..79825c52c63b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -8,7 +8,6 @@ import { buildMultiSelectThreadContextMenuItems, buildSidebarThreadWorktreeSections, buildSidebarV2ThreadContextMenuItems, - buildThreadContextMenuItems, createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, formatWorktreeGroupLabel, diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index b0d79acbfbab..308234090710 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -31,7 +31,7 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveNavigableUrl } from "~/browser/browserTargetResolver"; -import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; +import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index b65566d1395d..880a4d8e606b 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -470,6 +470,13 @@ export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { })); } +export function removePreviewThread(ref: ScopedThreadRef): void { + const threadKey = scopedThreadKey(ref); + appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); + syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); + changedPreviewThreadKeys.delete(threadKey); +} + export function isPreviewSupportedInRuntime(): boolean { if (typeof window === "undefined") return false; return Boolean(window.desktopBridge?.preview); diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index dab35ad3e08c..7703421d5c29 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDuration } from "./orchestrationTiming.ts"; +import { formatDuration, formatElapsed } from "./orchestrationTiming.ts"; describe("formatDuration", () => { it.each([ @@ -29,3 +29,9 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); + +describe("formatElapsed", () => { + it("formats a long run across midnight", () => { + expect(formatElapsed("2026-09-03T22:00:00Z", "2026-09-04T04:59:50Z")).toBe("6h 59m 50s"); + }); +}); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 98829ae052ad..956226e19a7d 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -28,6 +28,16 @@ export function formatDuration(durationMs: number): string { return parts.join(" "); } +export function formatElapsed(startIso: string, endIso: string | undefined): string | null { + if (!endIso) return null; + const startedAt = Date.parse(startIso); + const endedAt = Date.parse(endIso); + if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { + return null; + } + return formatDuration(endedAt - startedAt); +} + export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, From 0f95a3a72c938aabc90586a48b82d07b7f5eb7fa Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:13:06 +0200 Subject: [PATCH 101/103] fix(ssh): pass HOME in remote install diagnostic fixtures The new #10088 install-failure tests spawn with extendEnv:false, so $HOME is unset and the remote runner script dies under set -u. Made with grok-4.6 --- packages/ssh/src/runnerProcess.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index c9283d8de7e3..a696561d6cf2 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -235,6 +235,7 @@ if (mode === "etarget" || mode === "failed-with-path") { extendEnv: false, env: { PATH: bin, + HOME: fixture, T3_TEST_MODE: mode, T3_TEST_CLI: cliPath, T3_TEST_CALLS: callsPath, From 6a03cc45b075b168572efd3df5d159038f8c1526 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:15:57 +0200 Subject: [PATCH 102/103] fix(web): isolate settings-hydration tests from shared vite workers New welcome/settings-read tests mock ~/localApi; under isolate:false the real ensureLocalApi is already bound and they throw "Local API not found". Made with grok-4.6 --- apps/web/vite.config.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index fd660f91e574..247179c001a8 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -79,6 +79,11 @@ const isolatedUnitTestFiles = [ // Mocks `~/hooks/useSettings`; under isolate:false an earlier file binds // the real store and getBrowserDefaults never sees the test profile list. "src/browser/browserDefaults.test.ts", + // Mocks `~/hooks/useSettings`; under isolate:false the real hydrate path + // already bound ensureLocalApi, so failed-read tests throw "Local API not found". + "src/browser/browserLinkTarget.test.ts", + // Mocks `~/localApi` persistence; same isolate:false binding as useSettings. + "src/browser/HostedBrowserWebview.test.tsx", "src/browser/browserTargetResolver.test.ts", "src/browser/desktopTabLifetime.test.ts", "src/branding.test.ts", @@ -113,6 +118,9 @@ const isolatedUnitTestFiles = [ // applies — the icon falls back to the browser mockup and the stored-favicon // assertion fails, depending only on how files land across workers. "src/components/preview/PreviewFaviconIcon.test.tsx", + // Mocks `~/localApi` getClientSettings; under isolate:false the real + // localApi is already bound so open waits forever or never sees the mock. + "src/components/preview/PreviewAutomationHosts.test.tsx", "src/components/preview/PreviewView.test.tsx", "src/components/preview/openPreviewSession.test.ts", "src/components/preview/openTerminalLinkInPreview.test.ts", @@ -144,6 +152,9 @@ const isolatedUnitTestFiles = [ // Mocks `react` (useCallback/useMemo) and `@effect/atom-react`; under // isolate:false an earlier file binds real React and useContext is null. "src/hooks/useHandleNewThread.test.ts", + // Mocks `~/localApi` persistence; under isolate:false the real ensureLocalApi + // is already bound and hydration throws "Local API not found". + "src/hooks/useSettings.test.ts", "src/hooks/useLocalStorage.test.ts", "src/hooks/useTheme.test.ts", // Mocks `react` (useSyncExternalStore) like useTheme.test.ts; under From dee6bb34695e81a2b6625856dec16bd15598e2f9 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:29:11 +0200 Subject: [PATCH 103/103] fix(server): notice git init despite VCS detection cache Negative repository detects are TTL-cached for 15s, so #10078 could not resume checkpoints on the next turn after git init. Checkpoint isGitRepository now probes fresh. Made with grok-4.6 --- .../src/checkpointing/CheckpointStore.ts | 2 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 48 +++++++++++++++++++ apps/server/src/vcs/VcsDriverRegistry.ts | 8 +++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index b1ea7ee1e841..0767dc79022d 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -121,7 +121,7 @@ export const make = Effect.gen(function* () { const isGitRepository: CheckpointStore["Service"]["isGitRepository"] = (cwd) => vcsRegistry - .detect({ cwd, requestedKind: "git" }) + .detect({ cwd, requestedKind: "git", fresh: true }) .pipe(Effect.map((repository) => repository !== null)); const captureCheckpoint: CheckpointStore["Service"]["captureCheckpoint"] = Effect.fn( diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index b95a8d5ce913..785ad30cc13e 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -137,6 +137,54 @@ describe("VcsDriverRegistry", () => { // Negative detects are TTL-cached (15s); advance so a later repo creation is noticed. yield* TestClock.adjust("16 seconds"); assert.equal((yield* registry.detect({ cwd: "/repo" }))?.repository.rootPath, "/repo"); + assert.equal(probeChecks, 2); + }).pipe(Effect.provide(Layer.mergeAll(layer, TestClock.layer()))); + }); + + it.effect("fresh detect sees a repository created during a negative cache TTL", () => { + let probeChecks = 0; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + const command = normalizeGitArgs(input.args).join(" "); + if (command === "rev-parse --is-bare-repository --is-inside-work-tree") { + probeChecks += 1; + return probeChecks === 1 + ? { + ...processOutput(""), + exitCode: ChildProcessSpawner.ExitCode(128), + stderr: "fatal: not a git repository", + } + : processOutput("false\ntrue\n"); + } + if (command === "rev-parse --show-toplevel") { + return processOutput("/repo\n"); + } + if (command === "rev-parse --git-common-dir") { + return processOutput("/repo/.git\n"); + } + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + + assert.equal(yield* registry.detect({ cwd: "/repo" }), null); + assert.equal( + (yield* registry.detect({ cwd: "/repo", fresh: true }))?.repository.rootPath, + "/repo", + ); // One probe per detect: the combined rev-parse keeps negative detection // to a single git call. assert.equal(probeChecks, 2); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index a7ce0de2be65..2563e8409fe7 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -24,6 +24,8 @@ const DETECTION_NEGATIVE_CACHE_TTL = Duration.seconds(15); export interface VcsDriverResolveInput { readonly cwd: string; readonly requestedKind?: VcsDriverKind | "auto"; + /** Skip a cached miss so a just-created repo is visible on this call. */ + readonly fresh?: boolean; } export interface VcsDriverHandle { @@ -134,7 +136,11 @@ export const make = Effect.gen(function* () { const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( function* (input) { const requestedKind = yield* projectConfig.resolveKind(input); - return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); + const key = detectionCacheKey({ cwd: input.cwd, requestedKind }); + if (input.fresh) { + yield* Cache.invalidate(detectionCache, key); + } + return yield* Cache.get(detectionCache, key); }, );