From f86c5e8c8700b76250ed1073700a5b9db47e2d57 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:08:52 +0530 Subject: [PATCH 01/42] fix(server): skip IDE detection in Claude probes (#8634) --- .../src/provider/Layers/ClaudeCapabilitiesProbe.test.ts | 4 ++++ apps/server/src/provider/Layers/ClaudeProvider.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 7e5fa2611f0f..2f842bf581f7 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -22,6 +22,7 @@ it("isolates Claude capability probes without dropping workspace setting sources environment: { HOME: "/home/user", ENABLE_CLAUDEAI_MCP_SERVERS: "true", + FORCE_CODE_TERMINAL: "1", }, cwd: "/workspace/project", }); @@ -37,6 +38,9 @@ it("isolates Claude capability probes without dropping workspace setting sources assert.equal(options.abortController, abortController); assert.equal(options.env?.HOME, "/home/user"); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); + assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); + assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); + assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f815ac75be34..f37cf0534fea 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -614,6 +614,12 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { // Connected claude.ai MCP servers are discovered outside filesystem // config; disable them independently for this health check. ENABLE_CLAUDEAI_MCP_SERVERS: "false", + // This is a noninteractive health check, so IDE discovery cannot add any + // useful capability data. Skipping it also avoids Claude spawning a + // Windows `tasklist | findstr` process tree on every periodic refresh. + FORCE_CODE_TERMINAL: undefined, + CLAUDE_CODE_AUTO_CONNECT_IDE: "0", + CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", }, ...(input.cwd ? { cwd: input.cwd } : {}), stderr: () => {}, From ad38700ac678b8c8a0310d434a44d94a7ee6a47f Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:53:26 -0700 Subject: [PATCH 02/42] chore(macroscope): review diagnostic overrides (#8917) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .macroscope/approvability.md | 4 ++++ .macroscope/check-run-agents/effect-service-conventions.md | 1 + 2 files changed, 5 insertions(+) diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md index 37be2dc60772..ce4f160ae602 100644 --- a/.macroscope/approvability.md +++ b/.macroscope/approvability.md @@ -1,3 +1,7 @@ Use Macroscope's default approvability criteria. Additionally, any pull request that changes product defaults is not auto-approvable and requires human review. + +Any pull request that adds or broadens a directive that disables or suppresses a lint, +type-checker, LSP, or other static-analysis diagnostic is not auto-approvable and requires +human review. This includes file-level, line-level, and configuration-level overrides. diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index b76d56d45dbc..57254a1f6eeb 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -82,6 +82,7 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Change discipline - Preserve useful comments, invariants, and specification documentation while moving code. +- Require every new or broadened directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic to have an adjacent comment explaining why that diagnostic must be disabled there. The directive itself is not an explanation. Report a missing explanation as a concrete violation. - Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. - If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. - Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, separate error classes for diagnostic-only fields, or new tests for import-only changes. From 2921050c698fb195e2f5590b7cfab8fdfe0ec729 Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:21:36 +0800 Subject: [PATCH 03/42] fix(contracts): accept CLI event origins (#8905) --- .../src/persistence/Layers/OrchestrationEventStore.test.ts | 6 +++++- packages/contracts/src/baseSchemas.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 2bac5de920cb..1e21501e4096 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -17,7 +17,7 @@ const layer = it.layer( ); layer("OrchestrationEventStore", (it) => { - it.effect("stores json columns as strings and replays decoded events", () => + it.effect("stores json columns as strings and replays CLI-origin events", () => Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; @@ -34,6 +34,9 @@ layer("OrchestrationEventStore", (it) => { correlationId: CommandId.make("cmd-store-roundtrip"), metadata: { adapterKey: "codex", + origin: { + surface: "cli", + }, }, payload: { projectId: ProjectId.make("project-roundtrip"), @@ -66,6 +69,7 @@ layer("OrchestrationEventStore", (it) => { assert.equal(replayed.length, 1); assert.equal(replayed[0]?.type, "project.created"); assert.equal(replayed[0]?.metadata.adapterKey, "codex"); + assert.deepEqual(replayed[0]?.metadata.origin, { surface: "cli" }); }), ); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index aa6360a04edd..afa9e979b72d 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -86,12 +86,12 @@ export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); export type RpcClientId = typeof RpcClientId.Type; /** - * Which client app a connection comes from. Unlike + * Which client surface a connection or command comes from. Unlike * `AuthClientMetadataDeviceType` (a UA-style device class where web and * desktop are both "desktop"), this names the actual product surface. * Optional everywhere it appears: old clients never send it. */ -export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]); +export const ClientSurface = Schema.Literals(["web", "desktop", "mobile", "cli"]); export type ClientSurface = typeof ClientSurface.Type; export const ClientOs = Schema.Literals([ From bba79cc254b65969bde6b6bfc3032c3b5b9316ae Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:28:31 +0800 Subject: [PATCH 04/42] fix(web): hide invalid slash skill completions (#8904) --- apps/web/src/components/chat/ChatComposer.tsx | 14 ++++--- .../chat/composerSlashCommandSearch.test.ts | 39 ++++++++++++++++++- .../chat/composerSlashCommandSearch.ts | 10 +++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8937e52048e7..6972f1de41a7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -146,7 +146,10 @@ import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; -import { searchSlashCommandItems } from "./composerSlashCommandSearch"; +import { + searchSlashCommandItems, + slashCommandItemsForPromptPosition, +} from "./composerSlashCommandSearch"; import { getComposerPromptInjectionState, getComposerProviderState, @@ -1360,11 +1363,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) skill.description ?? (skill.scope ? `${skill.scope} skill` : ""), })); - const slashCommandItems = [ - ...builtInSlashCommandItems, - ...providerSlashCommandItems, - ...skillItems, - ]; + const slashCommandItems = slashCommandItemsForPromptPosition( + [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems], + composerTrigger.rangeStart === 0, + ); return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts index be749c0aae47..751aced7b835 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; -import { searchSlashCommandItems } from "./composerSlashCommandSearch"; +import { + searchSlashCommandItems, + slashCommandItemsForPromptPosition, +} from "./composerSlashCommandSearch"; describe("searchSlashCommandItems", () => { const claudeDriver = ProviderDriverKind.make("claudeAgent"); @@ -173,4 +176,38 @@ describe("searchSlashCommandItems", () => { "skill:claudeAgent:unslop", ]); }); + + it("hides skills from slash completion after the first message line", () => { + const items = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "skill:claudeAgent:unslop", + type: "skill", + provider: claudeDriver, + skill: { + name: "unslop", + path: "/skills/unslop/SKILL.md", + enabled: true, + }, + label: "/skill:unslop", + description: "Cut AI tells from writing", + }, + ] satisfies Array< + Extract + >; + + expect(slashCommandItemsForPromptPosition(items, false).map((item) => item.id)).toEqual([ + "slash:model", + ]); + expect(slashCommandItemsForPromptPosition(items, true).map((item) => item.id)).toEqual([ + "slash:model", + "skill:claudeAgent:unslop", + ]); + }); }); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 3e60cbf58b33..1578e0ec6f86 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -12,6 +12,16 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; +export function slashCommandItemsForPromptPosition( + items: ReadonlyArray, + isAtPromptStart: boolean, +): SlashSearchItem[] { + if (isAtPromptStart) { + return [...items]; + } + return items.filter((item) => item.type !== "skill"); +} + function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { if (item.type === "skill") { if (query === "skill") { From 746c932e164d49ddb4ac98a42176194b11bf0699 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 11:40:53 -0700 Subject: [PATCH 05/42] fix(mobile): defer draft navigation until submission completes (#8914) --- .../features/threads/NewTaskDraftScreen.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ca44385cb05d..50e2413d2afb 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,9 +1,11 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { + CommonActions, StackActions, useFocusEffect, useNavigation, usePreventRemove, + type NavigationAction, } from "@react-navigation/native"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; @@ -212,6 +214,9 @@ export function NewTaskDraftScreen(props: { const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); + const [submitNavigationAction, setSubmitNavigationAction] = useState( + null, + ); const [shareImportAttempt, setShareImportAttempt] = useState(0); const startedShareImportKeyRef = useRef(null); const cancellingShareImportKeyRef = useRef(null); @@ -275,12 +280,23 @@ export function NewTaskDraftScreen(props: { voiceInput.elapsedSeconds, ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; - usePreventRemove( + const preventRemove = (isIncomingShareTransferPending && !isProjectPickerReturnActive) || - isCancellingShareImport || - flow.submitting, - () => undefined, - ); + isCancellingShareImport || + flow.submitting; + usePreventRemove(preventRemove, () => undefined); + useEffect(() => { + if (preventRemove || submitNavigationAction === null) { + return; + } + // Give the guard update a frame to reach the parent sheet before navigating, + // just like the project-picker fallback below. + const frame = requestAnimationFrame(() => { + setSubmitNavigationAction(null); + (navigation.getParent() ?? navigation).dispatch(submitNavigationAction); + }); + return () => cancelAnimationFrame(frame); + }, [navigation, preventRemove, submitNavigationAction]); const hasImportedIncomingShare = Boolean( props.incomingShareId && flow.draftKey && @@ -872,7 +888,7 @@ export function NewTaskDraftScreen(props: { clearWorkspaceSelection: true, }); } - navigation.getParent()?.goBack(); + setSubmitNavigationAction(CommonActions.goBack()); return; } @@ -943,7 +959,7 @@ export function NewTaskDraftScreen(props: { clearWorkspaceSelection: true, }); } - navigation.dispatch( + setSubmitNavigationAction( StackActions.replace("Thread", { environmentId: String(result.value.environmentId), threadId: String(result.value.threadId), From 4e8e64fc065a4a72535eee5fe60b689f5b48d35c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 11:40:58 -0700 Subject: [PATCH 06/42] chore: disable CodeRabbit review status (#8933) --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000000..24c09911939d --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,2 @@ +reviews: + review_status: false From 038bf3739b871a56b2defdb09605344801448ced Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 11:42:55 -0700 Subject: [PATCH 07/42] Delete app.json (#8934) --- app.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 app.json diff --git a/app.json b/app.json deleted file mode 100644 index 306ca48315c1..000000000000 --- a/app.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "expo": {} -} From 35da5813315dc4e0c20602ed1918ceccc916b7eb Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:13:32 +0530 Subject: [PATCH 08/42] fix(web): show scrollbar for wide markdown tables (#8868) --- apps/web/src/components/ChatMarkdown.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a055759e638a..7773338a7459 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -657,12 +657,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - + {children}
From 5ce92c2f192040bf77c0211fa33bf03c74c031ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 11:44:56 -0700 Subject: [PATCH 09/42] fix(mobile): shimmer active tool rows (#8932) Co-authored-by: Julius Marminge --- .../src/features/threads/thread-work-log.tsx | 5 +++-- apps/mobile/src/lib/threadActivity.test.ts | 21 ++++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 6 ++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a316d9daee7c..dec1742d8261 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -9,6 +9,7 @@ import { type ColorValue, Pressable, ScrollView, + StyleSheet, View, } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -143,7 +144,7 @@ function ShimmeringWorkContent(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} > diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 73080b3dfcc6..28deeb384e5e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -942,8 +942,27 @@ describe("buildThreadFeed", () => { summary: "Running pnpm", summaryKind: "command", live: true, - shimmer: false, + shimmer: true, }); + expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + + const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); + expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); + + const completedRows = deriveThreadFeedPresentation( + feed, + { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, + new Set([turnId]), + new Set(), + latestTurn.startedAt, + ); + expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); }); it("does not revive cached in-progress tools after work stops", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f30081dd35b4..28f4fc760612 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1512,10 +1512,8 @@ function appendToolGroupRows( ), hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", live, - shimmer: - isWorking && - latestActivity.lifecycleStatus === "inProgress" && - latestActivity.turnId === unsettledTurnId, + // Match the live label until the turn or contiguous tool run settles. + shimmer: live, }); if (!expanded) { return; From 4a9d2d0ced2a2b899dbee9e4a5162fd83f81edb8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:55:51 +0200 Subject: [PATCH 10/42] chore(deps): bump Electron to 43.4.1 (#8626) --- apps/desktop/package.json | 2 +- .../src/preview/BrowserSession.test.ts | 2 +- apps/desktop/src/preview/BrowserSession.ts | 2 +- pnpm-lock.yaml | 105 ++++++------------ 4 files changed, 37 insertions(+), 74 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 91cd8f888080..b4d6e8d73958 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,7 +21,7 @@ "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", - "electron": "41.5.0", + "electron": "43.4.1", "electron-store": "^8.2.0", "electron-updater": "^6.6.2", "playwright-core": "1.60.0", diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 743fd6a1fcec..50798de916e0 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -184,7 +184,7 @@ describe("BrowserSession", () => { assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ { - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], }, ]); assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index e11d25bbed77..784afe019edf 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -168,7 +168,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { Effect.tryPromise({ try: () => browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], }), catch: (cause) => new BrowserSessionStorageClearError({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29785097d238..54e0b1218593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,7 +127,7 @@ importers: dependencies: '@clerk/electron': specifier: 0.0.37 - version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -153,8 +153,8 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) electron: - specifier: 41.5.0 - version: 41.5.0 + specifier: 43.4.1 + version: 43.4.1 electron-store: specifier: ^8.2.0 version: 8.2.0 @@ -542,7 +542,7 @@ importers: version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': specifier: 0.0.37 - version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': specifier: 6.14.7 version: 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2062,6 +2062,10 @@ packages: resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} + '@electron-internal/extract-zip@1.0.5': + resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} + engines: {node: '>=22.12.0'} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -2071,14 +2075,14 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.5.0': resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} @@ -4882,9 +4886,6 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1': resolution: {integrity: sha512-zs616um9UuaODLsNlCu5Aw95rFcTV4u3hVt090r6k0lVvTxfaJOv8HKA6BpIotcEYlZlMQowrMSYCCdedo7iyA==} engines: {node: '>=16.20.0'} @@ -5603,9 +5604,6 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6318,9 +6316,9 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.5.0: - resolution: {integrity: sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==} - engines: {node: '>= 12.20.55'} + electron@43.4.1: + resolution: {integrity: sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==} + engines: {node: '>= 22.12.0'} hasBin: true emmet@2.4.11: @@ -6363,6 +6361,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -6781,11 +6783,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-check@4.9.0: resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} @@ -6844,9 +6841,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -8561,9 +8555,6 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -10379,9 +10370,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yjs@13.6.31: resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -11478,12 +11466,12 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@clerk/clerk-js': 6.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - electron: 41.5.0 + electron: 43.4.1 react: 19.2.6 tslib: 2.8.1 optionalDependencies: @@ -11787,6 +11775,8 @@ snapshots: dependencies: '@types/hammerjs': 2.0.46 + '@electron-internal/extract-zip@1.0.5': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -11799,7 +11789,7 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@3.1.0': dependencies: debug: 4.4.3 env-paths: 2.2.1 @@ -11813,17 +11803,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@3.1.0': + '@electron/get@5.1.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.8.5 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 7.27.1 transitivePeerDependencies: - supports-color @@ -14702,11 +14691,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 24.12.4 - optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1': optional: true @@ -15536,8 +15520,6 @@ snapshots: bson@6.10.4: {} - buffer-crc32@0.2.13: {} - buffer-from@1.1.2: {} bufferutil@4.1.0: @@ -16154,11 +16136,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.5.0: + electron@43.4.1: dependencies: - '@electron/get': 2.0.3 + '@electron-internal/extract-zip': 1.0.5 + '@electron/get': 5.1.0 '@types/node': 24.12.4 - extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -16195,6 +16177,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} err-code@2.0.3: {} @@ -16836,16 +16820,6 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-check@4.9.0: dependencies: pure-rand: 8.4.0 @@ -16911,10 +16885,6 @@ snapshots: dependencies: bser: 2.1.1 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -19121,8 +19091,6 @@ snapshots: pe-library@0.4.1: {} - pend@1.2.0: {} - pg-cloudflare@1.4.0: optional: true @@ -21178,11 +21146,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yjs@13.6.31: dependencies: lib0: 0.2.117 From ef84bc9873a6c4565fbeb64dce3f552570e95a2d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 12:01:50 -0700 Subject: [PATCH 11/42] fix(chat): smooth worktree setup status (#8922) --- .../features/threads/NewTaskDraftScreen.tsx | 69 +++++++++------ .../src/features/threads/thread-work-log.tsx | 2 +- apps/mobile/src/lib/threadActivity.test.ts | 77 +++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 2 + .../web/src/components/ChatView.logic.test.ts | 62 +++++++++++++- apps/web/src/components/ChatView.logic.ts | 10 ++- apps/web/src/components/ChatView.tsx | 15 ++-- apps/web/src/components/chat/ChatComposer.tsx | 3 - .../chat/MessagesTimeline.logic.test.ts | 52 ++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 2 +- .../src/components/chat/MessagesTimeline.tsx | 72 ++++++++++------ .../chat/timelineScrollAnchoring.ts | 3 + apps/web/src/routes/_chat.draft.$draftId.tsx | 8 +- apps/web/src/session-logic.test.ts | 83 +++++++++++++++++++ apps/web/src/session-logic.ts | 2 + .../src/work-log/presentation.ts | 4 + 16 files changed, 397 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 50e2413d2afb..96ce039b5e04 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -39,6 +39,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; +import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -380,7 +381,8 @@ export function NewTaskDraftScreen(props: { }; }, [props.pendingTaskId, cancelEditingPendingTask]); - const foregroundColor = useUniwindTheme()["--color-foreground"]; + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); @@ -1110,31 +1112,50 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( - + + + ) : ( + <> + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => + flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") + } + showChevron={false} /> - } - label={workspaceLabel} - maxWidth={flow.workspaceMode === "local" ? 220 : 148} - onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} - showChevron={false} - /> - openContextPicker("NewTaskBranch")} - /> + openContextPicker("NewTaskBranch")} + /> + + )} ); diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index dec1742d8261..7e167f82eb44 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -79,7 +79,7 @@ function ShimmerWorkContent(props: { ); } -function ShimmeringWorkContent(props: { +export function ShimmeringWorkContent(props: { readonly icon: AppSymbolName; readonly iconSubtleColor: ColorValue; readonly label: string; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 28deeb384e5e..136b01190e31 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -234,6 +234,83 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps setup failures visible without routine setup notices before or after a turn", () => { + const thread = makeThread({ + id: ThreadId.make("thread-worktree-setup"), + projectId: ProjectId.make("project-1"), + title: "Worktree setup", + activities: [ + makeActivity({ + id: EventId.make("setup-requested"), + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: "2026-08-30T00:00:00.000Z", + }), + makeActivity({ + id: EventId.make("setup-started"), + kind: "setup-script.started", + summary: "Setup script started", + createdAt: "2026-08-30T00:00:01.000Z", + }), + makeActivity({ + id: EventId.make("setup-failed"), + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: "2026-08-30T00:00:02.000Z", + tone: "error", + payload: { detail: "Setup command was not found" }, + }), + ], + }); + const latestTurn = { + turnId: TurnId.make("turn-after-setup"), + state: "running" as const, + requestedAt: "2026-08-30T00:00:03.000Z", + startedAt: "2026-08-30T00:00:04.000Z", + completedAt: null, + assistantMessageId: null, + }; + + for (const currentTurn of [null, latestTurn]) { + const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn }); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [{ id: "setup-failed", status: "failure" }], + }, + ]); + const group = feed[0]; + if (group?.type !== "activity-group") throw new Error("Expected the setup failure group"); + expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found"); + } + }); + + it.each(["setup-script.requested", "setup-script.started"])( + "keeps error-toned %s notices visible", + (kind) => { + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-setup-error"), + projectId: ProjectId.make("project-1"), + title: "Setup error", + activities: [ + makeActivity({ + id: EventId.make("setup-error"), + kind, + summary: "Setup failed", + createdAt: "2026-08-30T00:00:00.000Z", + tone: "error", + }), + ], + }), + ); + + expect(feed).toMatchObject([ + { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] }, + ]); + }, + ); + it("keeps older local feedback before newer messages returned by the server", () => { const submission = { id: MessageId.make("feedback-command-ordering"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 28f4fc760612..367042448dac 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -14,6 +14,7 @@ import type { } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { + isWorktreeSetupActivity, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, summarizeToolGroup, @@ -343,6 +344,7 @@ function deriveWorkLogEntries( const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; // Terminal bypassed updates pass: Codex children's only terminal signal. diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 719d7b00013d..6e391ab79e95 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -115,7 +115,7 @@ describe("draft hero submission transition", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, - serverThreadStarted: true, + serverThread: makeThread({ latestTurn: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); @@ -316,6 +316,66 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("draft promotion during worktree setup", () => { + const serverThreadRef = { environmentId, threadId }; + + it.each([null, "idle", "starting", "ready"] as const)( + "keeps the draft mounted while the first turn waits with session %s", + (status) => { + const serverThread = makeThread({ + messages: [ + { + id: MessageId.make("submitted-message"), + role: "user", + text: "Start in a new worktree", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + session: status ? { ...readySession, status } : null, + }); + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending: false, + }), + ).toBeNull(); + }, + ); + + it("promotes when the provider starts the first turn", () => { + const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ + latestTurn, + session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId }, + }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }); + + it.each(["error", "stopped", "interrupted"] as const)( + "promotes a startup that ends as %s before a turn starts", + (status) => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ session: { ...readySession, status } }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }, + ); +}); + describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ae0b9885969e..a12bacad50dd 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -110,13 +110,19 @@ export function resolveDraftHeroState(input: { export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; - serverThreadStarted: boolean; + serverThread: Pick | null | undefined; backgroundSubmissionPending: boolean; }): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { return null; } - return input.serverThreadStarted ? input.serverThreadRef : null; + const sessionStatus = input.serverThread?.session?.status; + const turnStarted = input.serverThread?.latestTurn?.startedAt != null; + const startupStopped = + sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; + // Keep local preparation feedback mounted until the server can render the + // running turn or its startup error on the canonical thread route. + return turnStarted || startupStopped ? input.serverThreadRef : null; } export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e2b82e5a5f87..be978a8cc0c7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,7 +53,6 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { @@ -108,7 +107,11 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + getAnchoredTurnMetrics, + type TimelineScrollMode, +} from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -4110,7 +4113,7 @@ function ChatViewContent(props: ChatViewProps) { state, anchorIndex, composerOverlayHeight, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); }, [composerOverlayHeight], @@ -4138,7 +4141,7 @@ function ChatViewContent(props: ChatViewProps) { const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, + (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, @@ -4326,7 +4329,7 @@ function ChatViewContent(props: ChatViewProps) { index: anchorIndex, animated: true, viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, + viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }) .then(() => { if (positionedTimelineAnchorRef.current !== messageId) { @@ -6146,7 +6149,6 @@ function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); const backgroundThreadRef = resolvedSubmissionIntent === "background" ? scopeThreadRef(activeThread.environmentId, threadIdForSend) @@ -7225,6 +7227,7 @@ function ChatViewContent(props: ChatViewProps) { onOpenAgents={addAgentsSurface} key={activeThread.id} isWorking={isWorking} + isPreparingWorktree={isPreparingWorktree} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6972f1de41a7..cd69eceb7efd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -537,9 +537,6 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compactDisabledReason={props.compactDisabledReason} /> ) : null} - {props.isPreparingWorktree ? ( - Preparing worktree... - ) : null} { ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); }); it("does not fold the active in-progress turn", () => { @@ -942,6 +944,7 @@ describe("deriveMessagesTimelineRows", () => { }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, groupedEntries: [ @@ -1375,6 +1378,7 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.showAssistantMeta).toBe(false); expect(assistantRow?.showAssistantCopyButton).toBe(false); + expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); }); it.each([ @@ -1543,6 +1547,54 @@ describe("deriveMessagesTimelineRows", () => { }); describe("computeStableMessagesTimelineRows", () => { + it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => { + const startedAt = "2026-01-01T00:00:00Z"; + const turnId = TurnId.make("turn-1"); + const input = { + runningTurnId: turnId, + isWorking: true, + activeTurnStartedAt: startedAt, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + const assistantEntry = { + id: "assistant-entry", + kind: "message" as const, + createdAt: startedAt, + message: { + id: MessageId.make("assistant-1"), + role: "assistant" as const, + text, + turnId, + createdAt: startedAt, + updatedAt: startedAt, + streaming: true, + }, + }; + const initial = computeStableMessagesTimelineRows( + deriveMessagesTimelineRows({ ...input, timelineEntries: [assistantEntry] }), + { byId: new Map(), result: [] }, + ); + const updated = computeStableMessagesTimelineRows( + deriveMessagesTimelineRows({ + ...input, + timelineEntries: [ + { + ...assistantEntry, + message: { ...assistantEntry.message, text: "I will inspect the repository." }, + }, + ], + }), + initial, + ); + + const initialWorking = initial.byId.get("working-indicator-row"); + const updatedWorking = updated.byId.get("working-indicator-row"); + expect(initialWorking).toMatchObject({ showThinking: true }); + expect(updatedWorking).toMatchObject({ showThinking: false }); + expect(updatedWorking).not.toBe(initialWorking); + }); + it("returns the previous result when row order and content are unchanged", () => { const firstUserMessage = { id: "user-1" as never, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b339aab95445..9a8ad8f6c038 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -520,7 +520,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain('data-anchor-index="0"'); - expect(markup).toContain('data-anchor-offset="16"'); + expect(markup).toContain('data-anchor-offset="24"'); expect(markup).toContain('data-anchor-on-ready="true"'); expect(markup).not.toContain("data-anchor-max-size="); expect(markup).toContain('data-content-inset-end="144"'); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1fd5f5a7562a..81ab67f6ffe3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -84,7 +84,10 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; -import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + keepTimelineEndVisibleAfterOverlayGrowth, +} from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, @@ -169,6 +172,7 @@ interface TimelineRowSharedState { interface TimelineRowActivityState { isWorking: boolean; + isPreparingWorktree: boolean; isRevertingCheckpoint: boolean; latestTurnId: TurnId | null; } @@ -225,6 +229,7 @@ interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; + isPreparingWorktree?: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; timelineEntries: ReturnType; @@ -270,6 +275,7 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, + isPreparingWorktree = false, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, onOpenAgents = NOOP_OPEN_AGENTS, @@ -467,8 +473,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [anchorMessageId, onAnchorReady], ); const anchoredEndSpace = useMemo(() => { - const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => - row.kind === "message" && row.message.role === "user" ? row.message.id : null, + const config = resolveChatListAnchoredEndSpace( + rows, + anchorMessageId, + (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null), + { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, ); return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); @@ -577,10 +586,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const activityState = useMemo( () => ({ isWorking, + isPreparingWorktree, isRevertingCheckpoint, latestTurnId: latestTurn?.turnId ?? null, }), - [isRevertingCheckpoint, isWorking, latestTurn?.turnId], + [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId], ); // Stable renderItem — no closure deps. Row components read shared state @@ -1318,12 +1328,21 @@ function ProposedPlanTimelineRow({ } function WorkingTimelineRow({ row }: { row: Extract }) { + const { isPreparingWorktree } = use(TimelineRowActivityCtx); return (
-
- - {row.createdAt ? ( +
+ + {isPreparingWorktree ? ( + <> + Setting up worktree… + Setting up worktree… + + ) : row.createdAt ? ( <> Working for @@ -1334,8 +1353,9 @@ function WorkingTimelineRow({ row }: { row: Extract
{row.showThinking ? ( -
- + // Reserve the activity row during setup so the handoff keeps the same height. +
+ {isPreparingWorktree ? null : }
) : null}
@@ -1412,6 +1432,19 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); +function ActivityShimmerOverlay({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + function LiveActivityRow({ label, iconName, @@ -1429,24 +1462,13 @@ function LiveActivityRow({ failed={failed} announceFailure={failed} /> -
-
-
- -
-
-
+ + +
); } -function ThinkingActivityRow() { - return ; -} - function LiveActivityContent({ label, iconName, @@ -1463,7 +1485,7 @@ function LiveActivityContent({ const resolvedIconName = failed ? "circle-alert" : iconName; return ( -
) : null} {label} -
+ ); } diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index f38d0920b28b..505efef29d21 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,3 +1,6 @@ +// Match the titlebar fade inset so draft promotion preserves the first row's position. +export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; + export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; export interface TimelineListMeasurementState { diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index d067c6a8da9c..04cdf3ce8c9b 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,10 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; -import { - resolveDraftPromotionNavigationTarget, - threadHasStarted, -} from "../components/ChatView.logic"; +import { resolveDraftPromotionNavigationTarget } from "../components/ChatView.logic"; import { DraftId, markPromotedDraftThreadByRef, @@ -31,11 +28,10 @@ function DraftChatThreadRouteView() { : null; const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; const serverThread = useThread(serverThreadRef); - const serverThreadStarted = threadHasStarted(serverThread); const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ serverThreadRef, - serverThreadStarted, + serverThread, backgroundSubmissionPending, }); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 762ee3fae03a..dc32f84b1e5d 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -894,6 +894,89 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); + it("omits routine setup updates before work starts and after later turn activity", () => { + const setupActivities = [ + makeActivity({ + id: "setup-requested", + kind: "setup-script.requested", + summary: "Preparing setup script", + tone: "info", + sequence: 1, + }), + makeActivity({ + id: "setup-started", + kind: "setup-script.started", + summary: "Setup script started", + tone: "info", + sequence: 2, + }), + ]; + + expect(deriveWorkLogEntries(setupActivities)).toEqual([]); + expect( + deriveWorkLogEntries([ + ...setupActivities, + makeActivity({ + id: "first-turn-tool", + kind: "tool.completed", + summary: "Read project files", + turnId: "turn-1", + sequence: 3, + }), + makeActivity({ + id: "later-turn-tool", + kind: "tool.completed", + summary: "Ran tests", + turnId: "turn-2", + sequence: 4, + }), + ]).map((entry) => entry.id), + ).toEqual(["first-turn-tool", "later-turn-tool"]); + }); + + it("preserves setup failures and unrelated info without a turn id", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "setup-requested", + kind: "setup-script.requested", + summary: "Preparing setup script", + tone: "info", + sequence: 1, + }), + makeActivity({ + id: "setup-failed", + kind: "setup-script.failed", + summary: "Setup script failed to start", + tone: "error", + payload: { detail: "Could not start the setup terminal" }, + sequence: 2, + }), + makeActivity({ + id: "runtime-notice", + kind: "runtime.warning", + summary: "Reconnecting to provider", + tone: "info", + sequence: 3, + }), + ]); + + expect(entries).toMatchObject([ + { + id: "setup-failed", + label: "Setup script failed to start", + tone: "error", + detail: "Could not start the setup terminal", + turnId: null, + }, + { + id: "runtime-notice", + label: "Reconnecting to provider", + tone: "info", + turnId: null, + }, + ]); + }); + it("drops runtime warnings with no displayable content, keeps ones with a preview", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index ffde1b0c0412..6d853dc3ea1e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -2,6 +2,7 @@ import * as Option from "effect/Option"; import * as Arr from "effect/Array"; import * as Schema from "effect/Schema"; import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; +import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation"; import { ApprovalRequestId, isToolLifecycleItemType, @@ -809,6 +810,7 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 61ee4770e607..4834c037bb4c 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -1,5 +1,9 @@ import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; +export function isWorktreeSetupActivity(kind: string): boolean { + return kind === "setup-script.requested" || kind === "setup-script.started"; +} + export interface WorkLogPresentationEntry { readonly label: string; readonly toolTitle?: string; From 31c1c5996f88e3acf1566adc11c9b51ac7561554 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 12:06:29 -0700 Subject: [PATCH 12/42] feat(mobile): add video playback with native iOS controls (#8919) Co-authored-by: Julius Marminge --- .../ios/T3NativeControlsModule.swift | 77 ++++++ .../ios/T3NativePresentation.swift | 75 +++++ .../ios/T3NativeVideoPresentation.swift | 167 ++++++++++++ apps/mobile/package.json | 2 + .../components/ComposerAttachmentStrip.tsx | 175 +++++++++--- .../src/components/NativePresentation.ios.tsx | 12 + .../src/components/NativePresentation.tsx | 13 + .../src/components/VideoAttachmentMenu.tsx | 53 ++++ .../src/components/VideoAttachmentTile.tsx | 66 +++++ .../src/components/VideoPreviewModal.ios.tsx | 121 ++++++++ .../src/components/VideoPreviewModal.tsx | 258 ++++++++++++++++++ .../src/components/VideoThumbnailImage.tsx | 45 +++ .../features/threads/NewTaskDraftScreen.tsx | 23 ++ .../src/features/threads/ThreadComposer.tsx | 66 +++-- .../src/features/threads/ThreadFeed.tsx | 146 +++++++--- .../mobile/src/lib/attachmentDownload.test.ts | 136 ++++++++- apps/mobile/src/lib/attachmentDownload.ts | 180 +++++++++--- .../mobile/src/lib/composerAttachmentFiles.ts | 25 ++ apps/mobile/src/lib/composerFiles.test.ts | 168 ++++++++++-- apps/mobile/src/lib/composerImages.ts | 22 +- apps/mobile/src/lib/localVideoPreview.test.ts | 114 ++++++++ apps/mobile/src/lib/localVideoPreview.ts | 59 ++++ .../mobile/src/lib/shareFileFromSource.ios.ts | 14 + apps/mobile/src/lib/shareFileFromSource.ts | 9 + apps/mobile/src/lib/videoThumbnails.test.ts | 168 ++++++++++++ apps/mobile/src/lib/videoThumbnails.ts | 81 ++++++ .../src/state/use-composer-drafts.test.ts | 77 ++++++ apps/mobile/src/state/use-composer-drafts.ts | 20 +- apps/server/src/http.test.ts | 89 ++++++ apps/server/src/http.ts | 75 ++++- apps/web/src/types.ts | 24 +- docs/internals/mobile-navigation.md | 58 +++- docs/user/composer.md | 11 +- packages/shared/package.json | 4 + packages/shared/src/video.test.ts | 24 ++ packages/shared/src/video.ts | 22 ++ pnpm-lock.yaml | 28 ++ 37 files changed, 2494 insertions(+), 213 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift create mode 100644 apps/mobile/src/components/NativePresentation.ios.tsx create mode 100644 apps/mobile/src/components/NativePresentation.tsx create mode 100644 apps/mobile/src/components/VideoAttachmentMenu.tsx create mode 100644 apps/mobile/src/components/VideoAttachmentTile.tsx create mode 100644 apps/mobile/src/components/VideoPreviewModal.ios.tsx create mode 100644 apps/mobile/src/components/VideoPreviewModal.tsx create mode 100644 apps/mobile/src/components/VideoThumbnailImage.tsx create mode 100644 apps/mobile/src/lib/localVideoPreview.test.ts create mode 100644 apps/mobile/src/lib/localVideoPreview.ts create mode 100644 apps/mobile/src/lib/shareFileFromSource.ios.ts create mode 100644 apps/mobile/src/lib/shareFileFromSource.ts create mode 100644 apps/mobile/src/lib/videoThumbnails.test.ts create mode 100644 apps/mobile/src/lib/videoThumbnails.ts create mode 100644 packages/shared/src/video.test.ts create mode 100644 packages/shared/src/video.ts diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 6aa8fa6bb159..4f9073860723 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -3,9 +3,43 @@ import Security import UIKit public final class T3NativeControlsModule: Module { + private let presentationSources = T3PresentationSources() + private var videoPresentation: T3NativeVideoPresentation? + public func definition() -> ModuleDefinition { Name("T3NativeControls") + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in + try self.presentVideo( + url: url, + title: title, + sourceIdentifier: sourceIdentifier, + identifier: identifier, + promise: promise + ) + }.runOnQueue(.main) + + AsyncFunction("dismissVideo") { (identifier: String) in + self.dismissVideo(identifier: identifier) + }.runOnQueue(.main) + + OnDestroy { + let presentation = self.videoPresentation + DispatchQueue.main.async { presentation?.dismiss() } + } + + View(T3PresentationSourceView.self) { + ViewName("PresentationSource") + Prop("identifier") { (view: T3PresentationSourceView, identifier: String) in + view.sources = self.presentationSources + view.identifier = identifier + } + } + + AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in + try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) + }.runOnQueue(.main) + Function("getShowcasePairingUrl") { let arguments = ProcessInfo.processInfo.arguments guard @@ -101,4 +135,47 @@ public final class T3NativeControlsModule: Module { try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) } } + + private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { + let isPlayableURL = url.isFileURL + ? FileManager.default.isReadableFile(atPath: url.path) + : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) + guard videoPresentation == nil, + let presenter = appContext?.utilities?.currentViewController(), + isPlayableURL + else { + throw NSError( + domain: "T3NativeVideo", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."] + ) + } + let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in + self?.videoPresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + videoPresentation = presentation + presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) + } + + private func dismissVideo(identifier: String) { + if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } + } + + private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { + guard let presenter = appContext?.utilities?.currentViewController() else { + throw NSError( + domain: "T3NativePresentation", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] + ) + } + try presentFileShare( + url: url, + title: title, + source: presentationSources.view(for: sourceIdentifier), + presenter: presenter, + promise: promise + ) + } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift new file mode 100644 index 000000000000..f537e8704dcb --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift @@ -0,0 +1,75 @@ +import ExpoModulesCore +import UIKit + +final class T3PresentationSources { + private class Entry { + weak var view: UIView? + init(_ view: UIView) { self.view = view } + } + + private var entries: [String: Entry] = [:] + + func register(_ view: UIView, identifier: String) { + entries[identifier] = Entry(view) + } + + func remove(_ view: UIView, identifier: String) { + if entries[identifier]?.view == nil || entries[identifier]?.view === view { + entries.removeValue(forKey: identifier) + } + } + + func view(for identifier: String) -> UIView? { + // Use the child bounds, not the wrapper's potentially stretched layout bounds. + entries[identifier]?.view?.subviews.first + } +} + +final class T3PresentationSourceView: ExpoView { + weak var sources: T3PresentationSources? + var identifier = "" { + didSet { + sources?.remove(self, identifier: oldValue) + if !identifier.isEmpty { sources?.register(self, identifier: identifier) } + } + } + + deinit { + sources?.remove(self, identifier: identifier) + } +} + +func presentFileShare( + url: URL, + title: String, + source: UIView?, + presenter: UIViewController, + promise: Promise +) throws { + guard url.isFileURL, FileManager.default.isReadableFile(atPath: url.path) else { + throw NSError( + domain: "T3NativePresentation", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "The file is no longer available."] + ) + } + + guard let origin = source ?? presenter.view else { + throw NSError( + domain: "T3NativePresentation", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] + ) + } + + let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activity.title = title + activity.overrideUserInterfaceStyle = source?.traitCollection.userInterfaceStyle + ?? presenter.traitCollection.userInterfaceStyle + activity.completionWithItemsHandler = { _, _, _, _ in promise.resolve(nil) } + activity.modalPresentationStyle = .popover + activity.popoverPresentationController?.sourceView = origin + activity.popoverPresentationController?.sourceRect = source?.bounds + ?? CGRect(x: origin.bounds.midX, y: origin.bounds.maxY, width: 0, height: 0) + presenter.present(activity, animated: true) +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift new file mode 100644 index 000000000000..74d2f1c7551d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift @@ -0,0 +1,167 @@ +import AVKit +import UIKit + +final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, + UIAdaptivePresentationControllerDelegate { + let identifier: String + private let controller = AVPlayerViewController() + private let completion: (Error?) -> Void + private var itemObservation: NSKeyValueObservation? + private var backgroundObserver: NSObjectProtocol? + private var playbackError: Error? + private var presented = false + private var dismissRequested = false + private var finished = false + private struct AudioSessionConfiguration { + let category: AVAudioSession.Category + let mode: AVAudioSession.Mode + let options: AVAudioSession.CategoryOptions + + init(_ session: AVAudioSession) { + category = session.category + mode = session.mode + options = session.categoryOptions + } + } + private var previousAudioSession: AudioSessionConfiguration? + private weak var fullScreenController: UIViewController? + private var embedded = false + + init(identifier: String, url: URL, title: String, completion: @escaping (Error?) -> Void) { + self.identifier = identifier + self.completion = completion + super.init() + + let item = AVPlayerItem(url: url) + let metadata = AVMutableMetadataItem() + metadata.identifier = .commonIdentifierTitle + metadata.value = title as NSString + item.externalMetadata = [metadata] + controller.player = AVPlayer(playerItem: item) + controller.delegate = self + controller.overrideUserInterfaceStyle = .dark + controller.allowsPictureInPicturePlayback = false + + itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in + guard item.status == .failed else { return } + DispatchQueue.main.async { + guard let self else { return } + self.playbackError = item.error ?? NSError( + domain: "T3NativeVideo", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "This video couldn't be played on this device."] + ) + self.dismiss() + } + } + backgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in self?.controller.player?.pause() } + } + + func present(from presenter: UIViewController, sources: T3PresentationSources, sourceIdentifier: String) { + let audioSession = AVAudioSession.sharedInstance() + previousAudioSession = AudioSessionConfiguration(audioSession) + do { + try audioSession.setCategory(.playback, mode: .moviePlayback) + } catch { + NSLog("T3 video audio session: %@", error.localizedDescription) + } + // AVKit exposes programmatic inline-to-full-screen entry through this selector. + // This is the same guarded entry point used by expo-video's enterFullscreen(). + let enterFullScreen = NSSelectorFromString("enterFullScreenAnimated:completionHandler:") + if let source = sources.view(for: sourceIdentifier), source.window != nil, + controller.responds(to: enterFullScreen) { + // AVKit owns the transition from its inline view to full screen. Using a + // separate UIKit zoom transition prevents its native Close action from exiting. + var responder: UIResponder? = source + while let current = responder, !(current is UIViewController) { responder = current.next } + let parent = responder as? UIViewController ?? presenter + embedded = true + parent.addChild(controller) + controller.view.frame = source.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + source.addSubview(controller.view) + controller.didMove(toParent: parent) + controller.view.layoutIfNeeded() + controller.perform(enterFullScreen, with: true, with: nil) + controller.player?.play() + } else { + presenter.present(controller, animated: true) { [self] in + presented = true + if dismissRequested { + dismiss() + } else if UIApplication.shared.applicationState == .active { + controller.player?.play() + } + } + controller.presentationController?.delegate = self + } + } + + func dismiss() { + dismissRequested = true + guard !finished else { return } + guard presented else { + if embedded && fullScreenController == nil { finish() } + return + } + (fullScreenController ?? controller).dismiss(animated: true) { [self] in finish() } + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + fullScreenController = coordinator.viewController(forKey: .to) + coordinator.animate(alongsideTransition: nil) { [weak self] context in + guard let self else { return } + if context.isCancelled { + finish() + } else { + presented = true + if dismissRequested { dismiss() } + } + } + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + coordinator.animate(alongsideTransition: nil) { [weak self] context in + if !context.isCancelled { self?.finish() } + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finish() + } + + private func finish() { + guard !finished else { return } + finished = true + controller.player?.pause() + if embedded { + controller.willMove(toParent: nil) + controller.view.removeFromSuperview() + controller.removeFromParent() + } + itemObservation = nil + controller.player = nil + if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } + backgroundObserver = nil + let audioSession = AVAudioSession.sharedInstance() + if let previousAudioSession, audioSession.category == .playback, + audioSession.mode == .moviePlayback, audioSession.categoryOptions.isEmpty { + // AVPlayer owns activation. Deactivating the shared session here could + // stop another player or recorder that was active before this preview. + try? audioSession.setCategory( + previousAudioSession.category, + mode: previousAudioSession.mode, + options: previousAudioSession.options + ) + } + completion(playbackError) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3fcb1f76f3d9..488ca3e16b24 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -85,6 +85,7 @@ "expo-crypto": "~57.0.2", "expo-dev-client": "~57.0.16", "expo-device": "~57.0.1", + "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.6", "expo-font": "~57.0.2", "expo-glass-effect": "~57.0.1", @@ -102,6 +103,7 @@ "expo-sqlite": "~57.0.2", "expo-symbols": "~57.0.2", "expo-updates": "~57.0.19", + "expo-video": "~57.0.3", "expo-web-browser": "~57.0.2", "expo-widgets": "~57.0.15", "punycode": "^2.3.1", diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 3b8017fb1816..b18777c02260 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,8 +1,12 @@ import { SymbolView } from "../components/AppSymbol"; -import { Image, Pressable, ScrollView, View } from "react-native"; +import { videoMimeType } from "@t3tools/shared/video"; +import { useEffect, useRef, useState } from "react"; +import { Alert, Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; -import type { DraftComposerAttachment } from "../lib/composerImages"; +import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; +import { VideoAttachmentTile } from "./VideoAttachmentTile"; +import { loadLocalVideoPreview } from "../lib/localVideoPreview"; export interface ComposerAttachmentStripProps { /** Attachments to display. */ @@ -11,6 +15,10 @@ export interface ComposerAttachmentStripProps { readonly onRemove: (imageId: string) => void; /** Called when the user taps on an image thumbnail to preview it. */ readonly onPressImage?: (previewUri: string) => void; + readonly onPressVideo?: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; /** Image thumbnail size in points. Defaults to 72. */ readonly imageSize?: number; /** Border radius of each image thumbnail. Defaults to 16. */ @@ -19,6 +27,130 @@ export interface ComposerAttachmentStripProps { readonly removeButtonPlacement?: "overlay" | "gutter"; } +export function ComposerAttachmentThumbnail(props: { + readonly attachment: DraftComposerAttachment; + readonly size: number; + readonly borderRadius: number; + readonly compact?: boolean; + readonly onPressImage?: (previewUri: string) => void; + readonly onPressVideo?: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; +}) { + const { attachment } = props; + const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; + if (attachment.type === "image") { + return ( + props.onPressImage?.(attachment.previewUri) : undefined} + > + + + ); + } + const onPressVideo = props.onPressVideo; + if (onPressVideo && videoMimeType(attachment) !== null) { + return ( + + ); + } + return ( + + + {!props.compact ? ( + + {attachment.name} + + ) : null} + + ); +} + +function ComposerVideoAttachment(props: { + readonly attachment: DraftComposerFileAttachment; + readonly size: number; + readonly borderRadius: number; + readonly compact?: boolean; + readonly onPressVideo: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; +}) { + const { attachment } = props; + const sourceIdentifier = `draft:${attachment.id}`; + const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; + const shareRef = useRef(null); + const [sharing, setSharing] = useState(false); + useEffect( + () => () => { + shareRef.current?.abort(); + shareRef.current = null; + }, + [], + ); + + const onShare = () => { + if (shareRef.current) return; + const controller = new AbortController(); + shareRef.current = controller; + setSharing(true); + void (async () => { + const preview = await loadLocalVideoPreview(attachment, controller.signal); + if (!preview) return; + try { + await preview.share(controller.signal, sourceIdentifier); + } finally { + preview.dispose(); + } + })() + .catch((error: unknown) => { + if (!controller.signal.aborted) { + Alert.alert( + "Could not share video", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (shareRef.current === controller) { + shareRef.current = null; + setSharing(false); + } + }); + }; + + return ( + props.onPressVideo(attachment, sourceIdentifier)} + onShare={onShare} + disabled={sharing} + style={style} + /> + ); +} + /** * Attachment thumbnails used by the thread composer and the new-task draft screen. */ @@ -49,38 +181,13 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { paddingRight: removeButtonGutter, }} > - {attachment.type === "image" ? ( - props.onPressImage!(attachment.previewUri) : undefined - } - > - - - ) : ( - - - - {attachment.name} - - - )} + = requireNativeView( + "T3NativeControls", + "PresentationSource", +); + +export function PresentationSource(props: PresentationSourceProps) { + return ; +} diff --git a/apps/mobile/src/components/NativePresentation.tsx b/apps/mobile/src/components/NativePresentation.tsx new file mode 100644 index 000000000000..d48b8839540c --- /dev/null +++ b/apps/mobile/src/components/NativePresentation.tsx @@ -0,0 +1,13 @@ +import type { ReactElement } from "react"; +import { View, type ViewProps } from "react-native"; + +export interface PresentationSourceProps extends ViewProps { + readonly children: ReactElement; + /** Stable across remounts so dismissal can find a recycled attachment thumbnail. */ + readonly identifier: string; +} + +/** Registers the view as an iOS zoom or share-sheet origin. */ +export function PresentationSource({ identifier: _identifier, ...props }: PresentationSourceProps) { + return ; +} diff --git a/apps/mobile/src/components/VideoAttachmentMenu.tsx b/apps/mobile/src/components/VideoAttachmentMenu.tsx new file mode 100644 index 000000000000..301d6503a508 --- /dev/null +++ b/apps/mobile/src/components/VideoAttachmentMenu.tsx @@ -0,0 +1,53 @@ +import type { ReactElement } from "react"; +import { Platform, type PressableProps } from "react-native"; + +import { ControlPillMenu } from "./ControlPill"; +import { PresentationSource } from "./NativePresentation"; + +export function VideoAttachmentMenu(props: { + readonly sourceIdentifier: string; + readonly onOpen: () => void; + readonly onShare?: () => void; + readonly disabled?: boolean; + readonly children: ReactElement; +}) { + return ( + { + if (!props.disabled) props.onOpen(); + }} + accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} + onAccessibilityAction={({ nativeEvent }) => { + if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); + }} + > + {Platform.OS === "ios" && props.onShare ? ( + { + if (nativeEvent.event === "share") props.onShare?.(); + }} + > + {props.children} + + ) : ( + props.children + )} + + ); +} diff --git a/apps/mobile/src/components/VideoAttachmentTile.tsx b/apps/mobile/src/components/VideoAttachmentTile.tsx new file mode 100644 index 000000000000..6f582ac5f005 --- /dev/null +++ b/apps/mobile/src/components/VideoAttachmentTile.tsx @@ -0,0 +1,66 @@ +import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; + +import { cn } from "../lib/cn"; +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; +import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; +import { VideoThumbnailImage } from "./VideoThumbnailImage"; + +export function VideoAttachmentTile(props: { + readonly name: string; + readonly sourceIdentifier: string; + readonly thumbnailSource: string | DraftComposerFileAttachment | null; + readonly compact?: boolean; + readonly onPress: (sourceIdentifier: string) => void; + readonly onShare?: () => void; + readonly disabled?: boolean; + readonly className?: string; + readonly style?: StyleProp; +}) { + return ( + props.onPress(props.sourceIdentifier)} + onShare={props.onShare} + disabled={props.disabled} + > + props.onPress(props.sourceIdentifier)} + className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} + style={props.style} + > + + + + + {!props.compact ? ( + + + {props.name} + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx new file mode 100644 index 000000000000..4b2cfe27e6ff --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -0,0 +1,121 @@ +import { useIsFocused } from "@react-navigation/native"; +import { videoMimeType } from "@t3tools/shared/video"; +import { requireNativeModule } from "expo"; +import { useEffect, useEffectEvent, useId, useState } from "react"; +import { Alert, Keyboard } from "react-native"; + +import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import type { VideoPreviewSource } from "./VideoPreviewModal"; + +export type { VideoPreviewSource } from "./VideoPreviewModal"; + +const NativeControls = requireNativeModule<{ + presentVideo( + uri: string, + title: string, + sourceIdentifier: string, + identifier: string, + ): Promise; + dismissVideo(identifier: string): Promise; +}>("T3NativeControls"); + +function NativeVideoPreview(props: { + readonly source: VideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const { attachment } = source; + const identifier = useId(); + const onRequestClose = useEffectEvent(props.onRequestClose); + const environmentId = source.type === "remote" ? source.environmentId : null; + const preparedConnection = usePreparedConnection(environmentId); + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const assetUrl = useAssetUrlState( + environmentId, + source.type === "remote" + ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } + : null, + ); + const [playbackUrl, setPlaybackUrl] = useState(() => + assetUrl._tag === "Success" ? assetUrl.url : null, + ); + const loadError = + source.type === "remote" && playbackUrl === null + ? preparedConnection._tag === "None" + ? "Reconnect to this environment and open the video again." + : assetUrl._tag === "Failure" + ? "Could not load this video. Check the connection and try again." + : null + : null; + + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); + }, [playbackUrl, assetUrl]); + useEffect(() => { + if (!loadError) return; + Alert.alert("Could not open video", loadError); + onRequestClose(); + }, [loadError]); + + useEffect(() => { + if (source.type === "remote" && playbackUrl === null) return; + const controller = new AbortController(); + let ready = false; + void (async () => { + const file = + source.type === "local" + ? await loadLocalVideoPreview(source.attachment, controller.signal) + : null; + if (source.type === "local" && !file) return; + try { + if (controller.signal.aborted) return; + ready = true; + await NativeControls.presentVideo( + file?.uri ?? playbackUrl!, + attachment.name, + source.sourceIdentifier ?? "", + identifier, + ); + if (!controller.signal.aborted) onRequestClose(); + } finally { + // Native completion follows dismissal, so local playback keeps its file lease. + file?.dispose(); + } + })().catch((error: unknown) => { + if (controller.signal.aborted) return; + Alert.alert( + "Could not open video", + ready + ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." + : error instanceof Error + ? error.message + : "Could not load this video.", + ); + onRequestClose(); + }); + return () => { + controller.abort(); + void NativeControls.dismissVideo(identifier).catch(() => undefined); + }; + }, [source, attachment.name, playbackUrl, identifier]); + + return null; +} + +export function VideoPreviewModal(props: { + readonly source: VideoPreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource]); + + if (!props.source || !isFocused) return null; + return ; +} diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx new file mode 100644 index 000000000000..190f5c7b01bb --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -0,0 +1,258 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; +import { useEvent } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + AppState, + Keyboard, + Modal, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { + downloadAttachmentForPreview, + type AttachmentPreviewFile, +} from "../lib/attachmentDownload"; +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; + +export type VideoPreviewSource = ( + | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } + | { + readonly type: "remote"; + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + } +) & { readonly sourceIdentifier?: string }; + +function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { + const player = useVideoPlayer(props.file.uri, (player) => { + player.staysActiveInBackground = false; + if (AppState.currentState === "active") player.play(); + }); + const { status } = useEvent(player, "statusChange", { status: player.status }); + const shareControllerRef = useRef(null); + const [sharing, setSharing] = useState(false); + const [shareError, setShareError] = useState(null); + + useEffect( + () => () => { + shareControllerRef.current?.abort(); + shareControllerRef.current = null; + }, + [], + ); + + const onShare = () => { + if (shareControllerRef.current) return; + player.pause(); + const controller = new AbortController(); + shareControllerRef.current = controller; + setSharing(true); + setShareError(null); + void props.file + .share(controller.signal) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setShareError(error instanceof Error ? error.message : "Could not share this video."); + } + }) + .finally(() => { + if (shareControllerRef.current === controller) { + shareControllerRef.current = null; + setSharing(false); + } + }); + }; + + return ( + <> + + {status === "error" ? ( + + This video couldn't be played on this device. You can save or share the original file. + + ) : ( + <> + + {status === "loading" ? ( + + ) : null} + + )} + + + + {sharing ? "Opening share sheet..." : "Save or share video"} + + + {shareError ? ( + + {shareError} + + ) : null} + + ); +} + +function OpenVideoPreviewModal(props: { + readonly source: VideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const { attachment } = source; + const insets = useSafeAreaInsets(); + const environmentId = source.type === "remote" ? source.environmentId : null; + const preparedConnection = usePreparedConnection(environmentId); + const fileUri = source.type === "local" ? source.attachment.fileUri : null; + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const assetUrl = useAssetUrlState( + environmentId, + source.type === "remote" + ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } + : null, + ); + const [downloadUrl, setDownloadUrl] = useState(null); + const [file, setFile] = useState(null); + const [failure, setFailure] = useState(null); + + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { + setDownloadUrl(assetUrl.url); + } + }, [environmentId, downloadUrl, assetUrl]); + + useEffect(() => { + if (source.type === "remote" && downloadUrl === null) return; + const controller = new AbortController(); + let preview: AttachmentPreviewFile | null = null; + setFile(null); + setFailure(null); + const loading = + source.type === "local" + ? loadLocalVideoPreview(source.attachment, controller.signal) + : downloadAttachmentForPreview({ + url: downloadUrl!, + attachment: { name: attachment.name, mimeType }, + signal: controller.signal, + }); + void loading.then( + (loaded) => { + if (controller.signal.aborted) { + loaded?.dispose(); + return; + } + preview = loaded; + setFile(loaded); + }, + (error: unknown) => { + if (!controller.signal.aborted) { + setFailure(error instanceof Error ? error.message : "Could not load this video."); + } + }, + ); + return () => { + controller.abort(); + preview?.dispose(); + }; + }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); + + const loadError = + failure ?? + (environmentId !== null && downloadUrl === null + ? preparedConnection._tag === "None" + ? "This environment is disconnected. Reconnect and open the video again." + : assetUrl._tag === "Failure" + ? "Could not load this video. Check the connection to this environment and try again." + : null + : null); + + return ( + + + + + {attachment.name} + + + + + + {file ? ( + + ) : ( + + {loadError ? ( + + {loadError} + + ) : ( + <> + + Loading video... + + )} + + )} + + + ); +} + +export function VideoPreviewModal(props: { + readonly source: VideoPreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + useEffect(() => { + if (!isFocused && hasSource) props.onRequestClose(); + }, [isFocused, hasSource, props.onRequestClose]); + const { source } = props; + if (source === null || !isFocused) return null; + const key = + source.type === "local" + ? `local:${source.attachment.id}:${source.attachment.fileUri}` + : `remote:${source.environmentId}:${source.attachment.id}`; + return ; +} diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx new file mode 100644 index 000000000000..5af33499e4d1 --- /dev/null +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -0,0 +1,45 @@ +import { Image } from "expo-image"; +import { useIsFocused } from "@react-navigation/native"; +import type { VideoThumbnail } from "expo-video"; +import { useEffect, useState } from "react"; +import { StyleSheet } from "react-native"; + +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; + +export function VideoThumbnailImage(props: { + readonly cacheKey: string; + readonly source: string | DraftComposerFileAttachment | null; +}) { + const { cacheKey, source } = props; + const isFocused = useIsFocused(); + const [loaded, setLoaded] = useState<{ key: string; thumbnail: VideoThumbnail } | null>(null); + const thumbnail = loaded?.key === cacheKey ? loaded.thumbnail : cachedVideoThumbnail(cacheKey); + + useEffect(() => { + if (!source || !isFocused) return; + const controller = new AbortController(); + void loadVideoThumbnail( + cacheKey, + async (signal) => + typeof source === "string" + ? { uri: source, dispose: () => undefined } + : loadLocalVideoPreview(source, signal), + controller.signal, + ).then((thumbnail) => { + if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); + }); + return () => controller.abort(); + }, [cacheKey, source, isFocused]); + + return thumbnail ? ( + + ) : null; +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 96ce039b5e04..96445146e158 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -35,6 +35,7 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; @@ -60,6 +61,7 @@ import { convertPastedImagesToAttachments, pickComposerFiles, pickComposerMedia, + type DraftComposerFileAttachment, } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { @@ -164,6 +166,23 @@ export function NewTaskDraftScreen(props: { const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [previewVideo, setPreviewVideo] = useState(null); + const wasFocusedBeforeVideoRef = useRef(false); + const openVideoPreview = useCallback( + (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { + wasFocusedBeforeVideoRef.current = isComposerFocused; + setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); + }, + [isComposerFocused], + ); + const closeVideoPreview = useCallback(() => { + setPreviewVideo(null); + if (wasFocusedBeforeVideoRef.current) { + setTimeout(() => { + if (navigation.isFocused()) promptInputRef.current?.focus(); + }, 100); + } + }, [navigation]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, isEditorFocused: isComposerFocused, @@ -1193,6 +1212,9 @@ export function NewTaskDraftScreen(props: { ? () => undefined : flow.removeAttachment } + onPressVideo={ + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview + } /> ) : null} @@ -1296,6 +1318,7 @@ export function NewTaskDraftScreen(props: { + ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index b792ecd51e25..e1549cef96c2 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -19,7 +19,7 @@ import { useState, type RefObject, } from "react"; -import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native"; +import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; import ImageViewing from "react-native-image-viewing"; import Animated, { FadeIn, @@ -37,9 +37,12 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re import { scopedThreadKey } from "../../lib/scopedEntities"; import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; -import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { + ComposerAttachmentStrip, + ComposerAttachmentThumbnail, +} from "../../components/ComposerAttachmentStrip"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { @@ -48,7 +51,10 @@ import { ComposerToolbarRow, } from "../../components/ComposerToolbar"; import { ProviderIcon } from "../../components/ProviderIcon"; -import type { DraftComposerAttachment } from "../../lib/composerImages"; +import type { + DraftComposerAttachment, + DraftComposerFileAttachment, +} from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; @@ -306,6 +312,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onExpandedChange } = props; const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const showStopAction = !hasContent && @@ -368,6 +375,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const onPressImage = useCallback( (uri: string) => { wasExpandedBeforePreviewRef.current = isFocused; + setPreviewVideo(null); setPreviewImageUri(uri); }, [isFocused], @@ -375,10 +383,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const closePreview = useCallback(() => { setPreviewImageUri(null); + setPreviewVideo(null); if (wasExpandedBeforePreviewRef.current) { - setTimeout(() => inputRef.current?.focus(), 100); + setTimeout(() => { + if (navigation.isFocused()) inputRef.current?.focus(); + }, 100); } - }, [inputRef]); + }, [inputRef, navigation]); + + const onPressVideo = useCallback( + (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { + wasExpandedBeforePreviewRef.current = isFocused; + setPreviewImageUri(null); + setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); + }, + [isFocused], + ); const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { @@ -600,6 +620,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer attachments={props.draftAttachments} onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} onPressImage={voiceInput.isBusy ? undefined : onPressImage} + onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} /> ) : null} @@ -645,27 +666,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {!isExpanded && props.draftAttachments.length > 0 ? ( - {props.draftAttachments.slice(0, 3).map((attachment) => - attachment.type === "image" ? ( - onPressImage(attachment.previewUri)} - > - - - ) : ( - - - - ), - )} + {props.draftAttachments.slice(0, 3).map((attachment) => ( + + ))} {props.draftAttachments.length > 3 ? ( @@ -807,6 +818,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + void; }) { const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; + const videoType = videoMimeType(attachment); + const thumbnailUrl = useAssetUrl( + props.environmentId, + videoType === null + ? null + : { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: videoType, + }, + ); const httpBaseUrl = Option.isSome(preparedConnection) ? preparedConnection.value.httpBaseUrl : null; @@ -268,6 +284,69 @@ function MessageAttachmentFile(props: { }, [props.environmentId, attachment.id, httpBaseUrl]), ); + const shareFile = (sourceIdentifier?: string) => { + if (httpBaseUrl === null || openingRef.current) return; + const controller = new AbortController(); + openingRef.current = controller; + setOpening(true); + void (async () => { + try { + const result = await createAssetUrl({ + environmentId: props.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: attachment.mimeType, + }, + }, + }); + if (controller.signal.aborted) return; + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + if (url === null) { + throw new Error("The attachment could not be opened."); + } + await downloadAndShareAttachment({ + url, + attachment, + signal: controller.signal, + sourceIdentifier, + }); + } catch (error) { + if (!controller.signal.aborted) { + Alert.alert( + "Could not open attachment", + error instanceof Error ? error.message : "The attachment is unavailable.", + ); + } + } finally { + if (openingRef.current === controller) { + openingRef.current = null; + setOpening(false); + } + } + })(); + }; + + if (videoType !== null) { + return ( + props.onPressVideo(attachment, sourceIdentifier)} + onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} + className="my-1 rounded-2xl" + style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} + /> + ); + } + return ( { - if (httpBaseUrl === null || openingRef.current) return; - const controller = new AbortController(); - openingRef.current = controller; - setOpening(true); - void (async () => { - try { - const result = await createAssetUrl({ - environmentId: props.environmentId, - input: { - resource: { - _tag: "attachment", - attachmentId: attachment.id, - fileName: attachment.name, - mimeType: attachment.mimeType, - }, - }, - }); - if (controller.signal.aborted) return; - if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); - } - const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); - if (url === null) { - throw new Error("The attachment could not be opened."); - } - await downloadAndShareAttachment({ url, attachment, signal: controller.signal }); - } catch (error) { - if (!controller.signal.aborted) { - Alert.alert( - "Could not open attachment", - error instanceof Error ? error.message : "The attachment is unavailable.", - ); - } - } finally { - if (openingRef.current === controller) { - openingRef.current = null; - setOpening(false); - } - } - })(); - }} + onPress={() => shareFile()} > {opening ? ( @@ -1227,6 +1265,7 @@ function renderFeedEntry( readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly onMarkdownLinkPress: (href: string) => void; readonly renderMarkdownImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; @@ -1344,6 +1383,7 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachment={attachment} + onPressVideo={props.onPressVideo} /> ) : ( @@ -1404,6 +1444,7 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachment={attachment} + onPressVideo={props.onPressVideo} /> ) : ( @@ -1769,6 +1810,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { uri: string; headers?: Record; } | null>(null); + const [expandedVideo, setExpandedVideo] = useState(null); + useEffect(() => { + setExpandedVideo(null); + }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ viewportWidth, @@ -2243,6 +2288,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const onPressImage = useCallback((uri: string, headers?: Record) => { setExpandedImage({ uri, headers }); }, []); + const onPressVideo = useCallback( + (attachment: ChatFileAttachment, sourceIdentifier: string) => { + setExpandedVideo( + (current) => + current ?? { + type: "remote", + environmentId: props.environmentId, + attachment, + sourceIdentifier, + }, + ); + }, + [props.environmentId], + ); // Rows whose height is known before they ever render. Without this, every // row above the viewport is assumed to be estimatedItemSize tall, and @@ -2292,6 +2351,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, onToggleTurnFold, onPressImage, + onPressVideo, onMarkdownLinkPress, renderMarkdownImage, iconSubtleColor, @@ -2320,6 +2380,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onCopyWorkRow, onMarkdownLinkPress, onPressImage, + onPressVideo, onToggleTurnFold, onToggleWorkGroup, onToggleWorkRow, @@ -2503,6 +2564,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ) : null} + setExpandedVideo(null)} /> ({ directories: new Set(), deleted: vi.fn(), download: vi.fn(), + copy: vi.fn(), share: vi.fn(), + shareFromSource: vi.fn(), available: vi.fn(), uuid: vi.fn(), })); @@ -46,8 +48,12 @@ vi.mock("expo-file-system", () => { static downloadFileAsync = mocks.download; readonly uri: string; - constructor(directory: Directory, name: string) { - this.uri = `${directory.uri}/${encodeURIComponent(name)}`; + constructor(source: Directory | string, name?: string) { + this.uri = typeof source === "string" ? source : `${source.uri}/${encodeURIComponent(name!)}`; + } + + async copy(destination: File): Promise { + await mocks.copy(this.uri, destination.uri); } } @@ -60,8 +66,13 @@ vi.mock("expo-sharing", () => ({ })); vi.mock("./uuid", () => ({ uuidv4: mocks.uuid })); +vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource })); -import { downloadAndShareAttachment } from "./attachmentDownload"; +import { + downloadAndShareAttachment, + downloadAttachmentForPreview, + shareLocalAttachment, +} from "./attachmentDownload"; import { isForegroundHandoffActive } from "./foreground-handoff"; const NOW = 1_787_990_400_000; @@ -76,11 +87,15 @@ beforeEach(() => { mocks.directories.clear(); mocks.deleted.mockReset(); mocks.download.mockReset(); + mocks.copy.mockReset(); mocks.share.mockReset(); + mocks.shareFromSource.mockReset(); mocks.available.mockReset(); mocks.uuid.mockReset(); mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file); + mocks.copy.mockResolvedValue(undefined); mocks.share.mockResolvedValue(undefined); + mocks.shareFromSource.mockResolvedValue(undefined); mocks.available.mockResolvedValue(true); let sequence = 0; mocks.uuid.mockImplementation( @@ -268,3 +283,118 @@ describe("downloadAndShareAttachment", () => { await first; }); }); + +describe("attachment preview files", () => { + it("does not start a native request after cancellation during setup", async () => { + const controller = new AbortController(); + const loading = downloadAttachmentForPreview({ ...input, signal: controller.signal }); + controller.abort(); + await expect(loading).resolves.toBeNull(); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.share).not.toHaveBeenCalled(); + }); + + it("downloads for playback without requiring a share sheet and removes the file on close", async () => { + mocks.available.mockResolvedValue(false); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + expect(file?.uri.endsWith("/report.pdf")).toBe(true); + expect(mocks.available).not.toHaveBeenCalled(); + expect(mocks.deleted).not.toHaveBeenCalled(); + file?.dispose(); + file?.dispose(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "share-button"])( + "keeps a shared preview after its owner closes (source: %s)", + async (sourceIdentifier) => { + const opened = Promise.withResolvers(); + const sharing = Promise.withResolvers(); + const nativeShare = sourceIdentifier ? mocks.shareFromSource : mocks.share; + nativeShare.mockImplementationOnce(() => { + opened.resolve(); + return sharing.promise; + }); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + const share = file!.share(new AbortController().signal, sourceIdentifier); + await opened.promise; + file!.dispose(); + expect(mocks.deleted).not.toHaveBeenCalled(); + expect(isForegroundHandoffActive()).toBe(true); + sharing.resolve(); + await share; + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.deleted).not.toHaveBeenCalled(); + expect(mocks.download).toHaveBeenCalledTimes(1); + expect(mocks.copy).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, "share-button"])( + "does not share a disposed preview after availability checking (source: %s)", + async (sourceIdentifier) => { + const checking = Promise.withResolvers(); + const available = Promise.withResolvers(); + mocks.available.mockImplementation(() => { + checking.resolve(); + return available.promise; + }); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + const share = file!.share(new AbortController().signal, sourceIdentifier); + await checking.promise; + file!.dispose(); + available.resolve(true); + await share; + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.shareFromSource).not.toHaveBeenCalled(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }, + ); + + it("copies a local original before sharing without downloading or deleting the source", async () => { + const uri = "file:///documents/draft/report.pdf"; + await shareLocalAttachment({ + uri, + attachment: input.attachment, + signal: new AbortController().signal, + }); + expect(mocks.copy).toHaveBeenCalledWith( + uri, + expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/), + ); + expect(mocks.share).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], expect.any(Object)); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.deleted).not.toHaveBeenCalled(); + }); + + it("waits for a local copy to finish before cleaning up a canceled share", async () => { + const copying = Promise.withResolvers(); + const copied = Promise.withResolvers(); + mocks.copy.mockImplementation(() => { + copying.resolve(); + return copied.promise; + }); + const controller = new AbortController(); + const task = shareLocalAttachment({ + uri: "file:///documents/draft/report.pdf", + attachment: input.attachment, + signal: controller.signal, + }); + await copying.promise; + controller.abort(); + expect(mocks.deleted).not.toHaveBeenCalled(); + copied.resolve(); + await task; + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/attachmentDownload.ts b/apps/mobile/src/lib/attachmentDownload.ts index 8c9da9bc0073..2ae0c729c190 100644 --- a/apps/mobile/src/lib/attachmentDownload.ts +++ b/apps/mobile/src/lib/attachmentDownload.ts @@ -1,5 +1,6 @@ import type { ChatFileAttachment } from "@t3tools/contracts"; import type { Directory } from "expo-file-system"; +import type { SharingOptions } from "expo-sharing"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; @@ -52,23 +53,27 @@ function removeDownloadDirectory(directory: Directory): void { } } -/** Downloads original bytes for the native save/share sheet, including inline video responses. */ -export async function downloadAndShareAttachment(input: { - readonly url: string; - readonly attachment: Pick; - readonly signal: AbortSignal; -}): Promise { - const [{ Directory, File, Paths }, Sharing] = await Promise.all([ - import("expo-file-system"), - import("expo-sharing"), - ]); - if (input.signal.aborted) return; +type AttachmentFileMetadata = Pick; + +export interface AttachmentPreviewFile { + readonly uri: string; + readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise; + readonly dispose: () => void; +} + +async function availableSharing(signal: AbortSignal) { + if (signal.aborted) return null; + const Sharing = await import("expo-sharing"); const canShare = await Sharing.isAvailableAsync(); - if (input.signal.aborted) return; + if (signal.aborted) return null; if (!canShare) { throw new Error("Saving and sharing files is unavailable on this device."); } + return Sharing; +} +async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) { + const { Directory, File, Paths } = await import("expo-file-system"); const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY); cache.create({ idempotent: true, intermediates: true }); const now = Date.now(); @@ -89,40 +94,135 @@ export async function downloadAndShareAttachment(input: { } const directory = new Directory(cache, `${now}-${uuidv4()}`); + directory.create(); + let file: InstanceType; + try { + file = new File(directory, downloadFileName(attachment.name)); + } catch (error) { + removeDownloadDirectory(directory); + throw error; + } activeDirectories.add(directory.uri); + let disposed = false; let shared = false; - let openingShareSheet = false; - try { - directory.create(); - const destination = new File(directory, downloadFileName(input.attachment.name)); - const file = await File.downloadFileAsync(input.url, destination, { signal: input.signal }); - if (input.signal.aborted) return; + let sharing = false; + const release = () => { + if (!disposed || sharing) return; + activeDirectories.delete(directory.uri); + // A receiver can still be reading after Android's chooser returns. + if (!shared) removeDownloadDirectory(directory); + }; + const preview: AttachmentPreviewFile = { + uri: file.uri, + dispose: () => { + disposed = true; + release(); + }, + share: async (signal, sourceIdentifier) => { + if (disposed || sharing || signal.aborted) return; + sharing = true; + try { + const Sharing = await availableSharing(signal); + if (Sharing === null || disposed) return; + const endHandoff = beginForegroundHandoff(); + try { + const options: SharingOptions = { + mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", + dialogTitle: attachment.name, + }; + if (sourceIdentifier) { + const { shareFileFromSource } = await import("./shareFileFromSource"); + if (signal.aborted || disposed) return; + await shareFileFromSource(file.uri, options, sourceIdentifier); + } else { + await Sharing.shareAsync(file.uri, options); + } + shared = true; + } catch (cause) { + if (!signal.aborted) { + throw new Error("Could not open the share sheet. Try again.", { cause }); + } + } finally { + endHandoff(); + } + } finally { + sharing = false; + release(); + } + }, + }; + return { file, preview }; +} - openingShareSheet = true; - const endHandoff = beginForegroundHandoff(); - try { - await Sharing.shareAsync(file.uri, { - mimeType: input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", - dialogTitle: input.attachment.name, - }); - shared = true; - } finally { - endHandoff(); +/** The caller owns this cached file until disposal, unless it has been shared with another app. */ +export async function downloadAttachmentForPreview(input: { + readonly url: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; +}): Promise { + if (input.signal.aborted) return null; + const { File } = await import("expo-file-system"); + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) { + cached.preview.dispose(); + return null; + } + await File.downloadFileAsync(input.url, cached.file, { signal: input.signal }); + if (input.signal.aborted) { + cached.preview.dispose(); + return null; } + return cached.preview; } catch (cause) { - if (input.signal.aborted) return; - throw new Error( - openingShareSheet - ? "Could not open the share sheet. Try again." - : "Could not download the attachment. Check the connection and try again.", - { cause }, - ); + // Android may leave a partial file after a failed or interrupted request. + cached.preview.dispose(); + if (input.signal.aborted) return null; + throw new Error("Could not download the attachment. Check the connection and try again.", { + cause, + }); + } +} + +/** Downloads original bytes for the native save/share sheet, including inline video responses. */ +export async function downloadAndShareAttachment(input: { + readonly url: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; + readonly sourceIdentifier?: string; +}): Promise { + if ((await availableSharing(input.signal)) === null) return; + const file = await downloadAttachmentForPreview(input); + if (file === null) return; + try { + await file.share(input.signal, input.sourceIdentifier); } finally { - activeDirectories.delete(directory.uri); - // A receiver can still be reading after Android's chooser returns. - // Successful exports expire on a later open; partial downloads do not. - if (!shared) { - removeDownloadDirectory(directory); + file.dispose(); + } +} + +/** Shares a cache copy so another app never relies on the lifetime of a composer draft. */ +export async function shareLocalAttachment(input: { + readonly uri: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; + readonly sourceIdentifier?: string; +}): Promise { + if ((await availableSharing(input.signal)) === null) return; + const { File } = await import("expo-file-system"); + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) return; + try { + await new File(input.uri).copy(cached.file); + } catch (cause) { + if (input.signal.aborted) return; + throw new Error("Could not prepare the attachment for sharing.", { cause }); } + if (!input.signal.aborted) { + await cached.preview.share(input.signal, input.sourceIdentifier); + } + } finally { + cached.preview.dispose(); } } diff --git a/apps/mobile/src/lib/composerAttachmentFiles.ts b/apps/mobile/src/lib/composerAttachmentFiles.ts index a50daa30b627..963566b6ad88 100644 --- a/apps/mobile/src/lib/composerAttachmentFiles.ts +++ b/apps/mobile/src/lib/composerAttachmentFiles.ts @@ -6,6 +6,7 @@ const IOS_DOCUMENTS_PATH = new RegExp( `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`, "i", ); +const retainedFiles = new Map(); function fileUriPath(uri: string): string | null { try { @@ -52,6 +53,30 @@ export function composerAttachmentFileReferenceKey(uri: string): string { return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; } +/** Holds a local copy until its last player or share-copy operation releases it. */ +export function retainComposerAttachmentFile(uri: string, onLastRelease: () => void): () => void { + const key = composerAttachmentFileReferenceKey(uri); + retainedFiles.set(key, (retainedFiles.get(key) ?? 0) + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + const remaining = (retainedFiles.get(key) ?? 1) - 1; + if (remaining > 0) { + retainedFiles.set(key, remaining); + return; + } + retainedFiles.delete(key); + onLastRelease(); + }; +} + +export function isComposerAttachmentFileRetained(uri: string): boolean { + return retainedFiles.has(composerAttachmentFileReferenceKey(uri)); +} + /** * Resolves only our saved attachment copies. iOS preserves Documents on updates * but can change its container UUID. Picker and open-in-place source URIs must diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index f52bd9276cff..561b2a800aeb 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -23,8 +23,6 @@ vi.mock("expo-file-system", () => { } class File { - static pickFileAsync = mocks.pickFile; - readonly uri: string; constructor(source: string | Directory, name?: string) { @@ -75,6 +73,7 @@ vi.mock("expo-file-system", () => { }); vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); +vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); import { @@ -85,6 +84,7 @@ import { removePersistedComposerAttachmentFile, } from "./composerImages"; import { isForegroundHandoffActive } from "./foreground-handoff"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; describe("composer file attachments", () => { beforeEach(() => { @@ -274,11 +274,11 @@ describe("composer file attachments", () => { it("copies picked files into app-owned storage without loading their contents", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/report.pdf", name: "report.pdf", - type: "application/pdf", + mimeType: "application/pdf", size: 42, }, ], @@ -303,14 +303,120 @@ describe("composer file attachments", () => { ); }); + it("preserves Android picker metadata instead of using the content URI document id", async () => { + const uri = "content://com.android.providers.media.documents/document/video%3A18"; + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri, + name: "preview-h264.mp4", + mimeType: "video/mp4", + size: 620_992, + lastModified: 0, + }, + ], + }); + mocks.size.mockReturnValue(620_992); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [ + { + id: "attachment-id", + type: "file", + name: "preview-h264.mp4", + mimeType: "video/mp4", + sizeBytes: 620_992, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", + }, + ], + error: null, + }); + expect(mocks.pickFile).toHaveBeenCalledWith({ multiple: true, copyToCacheDirectory: true }); + expect(mocks.copy).toHaveBeenCalledWith( + uri, + "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", + ); + expect(mocks.delete).not.toHaveBeenCalled(); + }); + + it("persists provider selections that require a readable cache copy", async () => { + const providerUri = "content://cloud-provider/documents/clip"; + const cachedUri = "file:///cache/DocumentPicker/clip.mp4"; + mocks.pickFile.mockImplementation(async (options) => ({ + canceled: false, + assets: [ + { + uri: options.copyToCacheDirectory ? cachedUri : providerUri, + name: "Cloud recording.mp4", + mimeType: "video/mp4", + size: 42, + lastModified: 0, + }, + ], + })); + mocks.copy.mockImplementation((uri: string) => { + if (uri === providerUri) throw new Error("The provider URI is not directly readable."); + }); + + const result = await pickComposerFiles({ existingCount: 0 }); + + expect(result.error).toBeNull(); + expect(result.files).toEqual([ + expect.objectContaining({ + name: "Cloud recording.mp4", + fileUri: "file:///documents/t3-composer-attachments/attachment-id-Cloud recording.mp4", + }), + ]); + expect(mocks.copy).toHaveBeenCalledWith(cachedUri, result.files[0]!.fileUri); + }); + + it("ends the foreground handoff when the picker is canceled without copying files", async () => { + mocks.pickFile.mockImplementation(async () => { + expect(isForegroundHandoffActive()).toBe(true); + return { canceled: true, assets: null }; + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: null, + }); + + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.copy).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); + }); + + it("reports picker failures and releases the foreground handoff", async () => { + mocks.pickFile.mockRejectedValue(new Error("The document provider is unavailable.")); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: "The document provider is unavailable.", + }); + + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("does not open the picker when the draft has no remaining attachment slots", async () => { + await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({ + files: [], + error: "You can attach up to 8 files per message.", + }); + + expect(mocks.pickFile).not.toHaveBeenCalled(); + expect(isForegroundHandoffActive()).toBe(false); + }); + it("falls back to a usable name when the picker reports a blank one", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/unnamed", name: " ", - type: "application/pdf", + mimeType: "application/pdf", size: 42, }, ], @@ -325,11 +431,11 @@ describe("composer file attachments", () => { it("rejects files that exceed the environment's advertised upload limit", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/archive.zip", name: "archive.zip", - type: "application/zip", + mimeType: "application/zip", size: 2 * 1024 * 1024, }, ], @@ -345,11 +451,11 @@ describe("composer file attachments", () => { it("never accepts files above the 50 MB contract limit", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/archive.zip", name: "archive.zip", - type: "application/zip", + mimeType: "application/zip", size: 51 * 1024 * 1024, }, ], @@ -366,11 +472,11 @@ describe("composer file attachments", () => { it("rejects a file that grew after the picker reported its size", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/archive.zip", name: "archive.zip", - type: "application/zip", + mimeType: "application/zip", size: 42, }, ], @@ -434,11 +540,11 @@ describe("composer file attachments", () => { mocks.size.mockReturnValue(0); mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/empty.txt", name: "empty.txt", - type: "text/plain", + mimeType: "text/plain", size: 0, }, ], @@ -450,7 +556,7 @@ describe("composer file attachments", () => { }); }); - it("copies an Android SAF file when the picker reports an unknown zero size", async () => { + it.each([0, undefined])("copies an Android SAF file when the picker size is %s", async (size) => { const reader = { readBytes: vi .fn() @@ -463,12 +569,12 @@ describe("composer file attachments", () => { mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer)); mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "content://shared/report", name: "report.pdf", - type: "application/pdf", - size: 0, + mimeType: "application/pdf", + size, }, ], }); @@ -491,17 +597,17 @@ describe("composer file attachments", () => { it("uses the remaining slot for the first valid file after an oversized selection", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, - result: [ + assets: [ { uri: "file:///downloads/huge.zip", name: "huge.zip", - type: "application/zip", + mimeType: "application/zip", size: 2 * 1024 * 1024, }, { uri: "file:///downloads/report.pdf", name: "report.pdf", - type: "application/pdf", + mimeType: "application/pdf", size: 42, }, ], @@ -557,6 +663,26 @@ describe("composer file attachments", () => { ]); }); + it("rechecks preview ownership after loading the native filesystem", async () => { + const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; + const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`; + mocks.documentUri = + "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; + const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`; + + const deleting = removePersistedComposerAttachmentFile(oldUri); + const release = retainComposerAttachmentFile(currentUri, () => {}); + try { + await deleting; + expect(mocks.delete).not.toHaveBeenCalled(); + } finally { + release(); + } + + await removePersistedComposerAttachmentFile(oldUri); + expect(mocks.delete.mock.calls).toEqual([[currentUri]]); + }); + it("copies an open-in-place source from its actual container without rebasing it", async () => { const sourceUri = "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf"; diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index c19193150213..b4684e5bfc6f 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,10 +10,11 @@ import { type EnvironmentId, type UploadChatImageAttachment, } from "@t3tools/contracts"; -import type { PickMultipleFilesResult } from "expo-file-system"; +import type { DocumentPickerResult } from "expo-document-picker"; import { estimateBase64ByteSize } from "./base64"; import { COMPOSER_ATTACHMENT_DIRECTORY, + isComposerAttachmentFileRetained, resolveOwnedComposerAttachmentFileUri, } from "./composerAttachmentFiles"; import { beginForegroundHandoff } from "./foreground-handoff"; @@ -145,7 +146,7 @@ export async function removePersistedComposerAttachmentFile(uri: string): Promis try { const { File, Paths } = await import("expo-file-system"); const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri); - if (ownedUri === null) { + if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) { return; } const file = new File(ownedUri); @@ -206,11 +207,18 @@ export async function pickComposerFiles(input: { }; } - const { File } = await import("expo-file-system"); + const { getDocumentAsync } = await import("expo-document-picker"); const endHandoff = beginForegroundHandoff(); - let result: PickMultipleFilesResult; + let result: DocumentPickerResult; try { - result = await File.pickFileAsync({ multipleFiles: true }); + // File providers may expose a URI that FileSystem cannot read directly. + // Import a readable cache copy before persisting the draft's owned file. + result = await getDocumentAsync({ multiple: true, copyToCacheDirectory: true }); + } catch (cause) { + return { + files: [], + error: cause instanceof Error ? cause.message : "Could not open the file picker.", + }; } finally { endHandoff(); } @@ -224,7 +232,7 @@ export async function pickComposerFiles(input: { const attachments: DraftComposerFileAttachment[] = []; let error: string | null = null; let exceededAttachmentLimit = false; - for (const file of result.result) { + for (const file of result.assets) { if (attachments.length >= remainingSlots) { exceededAttachmentLimit = true; break; @@ -238,7 +246,7 @@ export async function pickComposerFiles(input: { await createComposerFileAttachment({ uri: file.uri, name, - mimeType: file.type || "application/octet-stream", + mimeType: file.mimeType || "application/octet-stream", sizeBytes: file.size ?? null, maxBytes, }), diff --git a/apps/mobile/src/lib/localVideoPreview.test.ts b/apps/mobile/src/lib/localVideoPreview.test.ts new file mode 100644 index 000000000000..706c61521682 --- /dev/null +++ b/apps/mobile/src/lib/localVideoPreview.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + retain: vi.fn(), + share: vi.fn(), + exists: vi.fn(), +})); + +vi.mock("../state/use-composer-drafts", () => ({ + retainComposerAttachmentFileForPreview: mocks.retain, +})); +vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); +vi.mock("expo-file-system", () => ({ + File: class { + constructor(readonly uri: string) {} + get exists(): boolean { + return mocks.exists(this.uri); + } + }, + Paths: { + document: { + uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/", + }, + }, +})); + +import { loadLocalVideoPreview } from "./localVideoPreview"; + +const attachment = { + type: "file" as const, + id: "draft-video", + name: "clip.mov", + mimeType: "video/quicktime", + sizeBytes: 12, + fileUri: + "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov", +}; + +beforeEach(() => { + mocks.retain.mockReset(); + mocks.share.mockReset(); + mocks.exists.mockReset(); + mocks.retain.mockImplementation(() => vi.fn()); + mocks.exists.mockReturnValue(true); + mocks.share.mockResolvedValue(undefined); +}); + +describe("loadLocalVideoPreview", () => { + it("resolves the current iOS container and releases its playback lease once", async () => { + const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); + expect(mocks.retain).toHaveBeenCalledWith(attachment); + const release = mocks.retain.mock.results[0]!.value; + expect(release).not.toHaveBeenCalled(); + preview?.dispose(); + preview?.dispose(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "share-button"])( + "keeps a separate share lease after playback closes (source: %s)", + async (sourceIdentifier) => { + const shared = Promise.withResolvers(); + mocks.share.mockReturnValue(shared.promise); + const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + const share = preview!.share(new AbortController().signal, sourceIdentifier); + expect(mocks.retain).toHaveBeenCalledTimes(2); + const releasePlayback = mocks.retain.mock.results[0]!.value; + const releaseShare = mocks.retain.mock.results[1]!.value; + preview!.dispose(); + expect(releasePlayback).toHaveBeenCalledTimes(1); + expect(releaseShare).not.toHaveBeenCalled(); + shared.resolve(); + await share; + expect(releaseShare).toHaveBeenCalledTimes(1); + }, + ); + + it("releases a failed share while keeping playback retained", async () => { + mocks.share.mockRejectedValue(new Error("Sharing unavailable")); + const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + await expect(preview!.share(new AbortController().signal)).rejects.toThrow( + "Sharing unavailable", + ); + expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + }); + + it("releases a load canceled during native module loading", async () => { + const controller = new AbortController(); + const loading = loadLocalVideoPreview(attachment, controller.signal); + controller.abort(); + await expect(loading).resolves.toBeNull(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + expect(mocks.exists).not.toHaveBeenCalled(); + }); + + it("reports missing files and releases their lease", async () => { + mocks.exists.mockReturnValue(false); + await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow( + "This video is no longer available. Attach the file again.", + ); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); + + it("does not start sharing a disposed preview", async () => { + const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + preview!.dispose(); + await preview!.share(new AbortController().signal); + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.retain).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/localVideoPreview.ts b/apps/mobile/src/lib/localVideoPreview.ts new file mode 100644 index 000000000000..8398124b96ce --- /dev/null +++ b/apps/mobile/src/lib/localVideoPreview.ts @@ -0,0 +1,59 @@ +import { videoMimeType } from "@t3tools/shared/video"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; +import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; + +/** Retains the draft original for playback and gives each outgoing share its own lease. */ +export async function loadLocalVideoPreview( + attachment: DraftComposerFileAttachment, + signal: AbortSignal, +): Promise { + if (signal.aborted) return null; + const release = retainComposerAttachmentFileForPreview(attachment); + try { + const { File, Paths } = await import("expo-file-system"); + if (signal.aborted) { + release(); + return null; + } + const uri = + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri; + const file = new File(uri); + if (!file.exists) { + throw new Error("The local attachment file is missing."); + } + let disposed = false; + return { + uri: file.uri, + dispose: () => { + if (disposed) return; + disposed = true; + release(); + }, + share: async (shareSignal, sourceIdentifier) => { + if (disposed || shareSignal.aborted) return; + const releaseShare = retainComposerAttachmentFileForPreview(attachment); + try { + await shareLocalAttachment({ + uri: file.uri, + attachment: { + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + signal: shareSignal, + sourceIdentifier, + }); + } finally { + releaseShare(); + } + }, + }; + } catch (cause) { + release(); + if (signal.aborted) return null; + throw new Error("This video is no longer available. Attach the file again.", { cause }); + } +} diff --git a/apps/mobile/src/lib/shareFileFromSource.ios.ts b/apps/mobile/src/lib/shareFileFromSource.ios.ts new file mode 100644 index 000000000000..5de0e0cbd423 --- /dev/null +++ b/apps/mobile/src/lib/shareFileFromSource.ios.ts @@ -0,0 +1,14 @@ +import { requireNativeModule } from "expo"; +import type { SharingOptions } from "expo-sharing"; + +const NativeControls = requireNativeModule<{ + shareFileFromSource(uri: string, title: string, sourceIdentifier: string): Promise; +}>("T3NativeControls"); + +export function shareFileFromSource( + uri: string, + options: SharingOptions, + sourceIdentifier: string, +) { + return NativeControls.shareFileFromSource(uri, options.dialogTitle ?? "", sourceIdentifier); +} diff --git a/apps/mobile/src/lib/shareFileFromSource.ts b/apps/mobile/src/lib/shareFileFromSource.ts new file mode 100644 index 000000000000..5e806612a046 --- /dev/null +++ b/apps/mobile/src/lib/shareFileFromSource.ts @@ -0,0 +1,9 @@ +import { shareAsync, type SharingOptions } from "expo-sharing"; + +export function shareFileFromSource( + uri: string, + options: SharingOptions, + _sourceIdentifier: string, +) { + return shareAsync(uri, options); +} diff --git a/apps/mobile/src/lib/videoThumbnails.test.ts b/apps/mobile/src/lib/videoThumbnails.test.ts new file mode 100644 index 000000000000..e577e448ea2a --- /dev/null +++ b/apps/mobile/src/lib/videoThumbnails.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ createPlayer: vi.fn() })); +vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer })); + +let thumbnails: typeof import("./videoThumbnails"); +const frame = { width: 480, height: 270 }; +const player = () => ({ + replaceAsync: vi.fn(async (): Promise => {}), + generateThumbnailsAsync: vi.fn(async () => [frame]), + release: vi.fn(), +}); +const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() }); + +beforeEach(async () => { + vi.resetModules(); + mocks.createPlayer.mockReset().mockImplementation(player); + thumbnails = await import("./videoThumbnails"); +}); + +afterEach(() => vi.useRealTimers()); + +describe("video thumbnails", () => { + it("reuses a frame for duplicate requests and refreshed signed URLs", async () => { + const file = source(); + const resolveSource = vi.fn(async () => file); + const signal = new AbortController().signal; + const results = await Promise.all([ + thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), + thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), + ]); + expect(results).toEqual([frame, frame]); + expect(resolveSource).toHaveBeenCalledTimes(1); + expect(mocks.createPlayer).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + const refreshed = vi.fn(async () => ({ ...source(), uri: "https://host/new-token/clip.mp4" })); + expect(await thumbnails.loadVideoThumbnail("env:clip", refreshed, signal)).toBe(frame); + expect(refreshed).not.toHaveBeenCalled(); + }); + + it("serializes decoding and skips queued requests that scroll out of view", async () => { + const started = Promise.withResolvers(); + const generated = Promise.withResolvers<(typeof frame)[]>(); + const first = player(); + first.generateThumbnailsAsync.mockImplementation(() => { + started.resolve(); + return generated.promise; + }); + mocks.createPlayer.mockReturnValueOnce(first); + const firstRequest = thumbnails.loadVideoThumbnail( + "first", + async () => source(), + new AbortController().signal, + ); + await started.promise; + const removed = new AbortController(); + const skipped = vi.fn(async () => source()); + const queued = thumbnails.loadVideoThumbnail("removed", skipped, removed.signal); + const next = vi.fn(async () => source()); + const nextRequest = thumbnails.loadVideoThumbnail("next", next, new AbortController().signal); + expect(next).not.toHaveBeenCalled(); + removed.abort(); + generated.resolve([frame]); + expect(await firstRequest).toBe(frame); + expect(await queued).toBeNull(); + expect(await nextRequest).toBe(frame); + expect(skipped).not.toHaveBeenCalled(); + expect(first.release).toHaveBeenCalledTimes(1); + }); + + it("releases an active canceled player and ignores late source loading", async () => { + const started = Promise.withResolvers(); + const replaced = Promise.withResolvers(); + const first = player(); + first.replaceAsync.mockImplementation(() => { + started.resolve(); + return replaced.promise; + }); + mocks.createPlayer.mockReturnValueOnce(first); + const file = source(); + const controller = new AbortController(); + const request = thumbnails.loadVideoThumbnail("canceled", async () => file, controller.signal); + await started.promise; + controller.abort(); + expect(await request).toBeNull(); + expect(first.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + replaced.resolve(); + expect( + await thumbnails.loadVideoThumbnail( + "next", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + expect(first.generateThumbnailsAsync).not.toHaveBeenCalled(); + expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull(); + }); + + it("releases failed extractions and permits a later retry", async () => { + const broken = player(); + broken.generateThumbnailsAsync.mockRejectedValue(new Error("Invalid video")); + mocks.createPlayer.mockReturnValueOnce(broken); + const file = source(); + expect( + await thumbnails.loadVideoThumbnail("retry", async () => file, new AbortController().signal), + ).toBeNull(); + expect(broken.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + expect( + await thumbnails.loadVideoThumbnail( + "retry", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + }); + + it("does not let an unreachable source block the queue indefinitely", async () => { + vi.useFakeTimers(); + const started = Promise.withResolvers(); + const first = player(); + first.replaceAsync.mockImplementation(() => { + started.resolve(); + return new Promise(() => {}); + }); + mocks.createPlayer.mockReturnValueOnce(first); + const file = source(); + const request = thumbnails.loadVideoThumbnail( + "unreachable", + async () => file, + new AbortController().signal, + ); + await started.promise; + await vi.advanceTimersByTimeAsync(15_000); + expect(await request).toBeNull(); + expect(first.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + expect( + await thumbnails.loadVideoThumbnail( + "reachable", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + }); + + it("bounds the retained native images without invalidating frames still displayed", async () => { + for (let i = 0; i < 33; i++) { + await thumbnails.loadVideoThumbnail( + `clip:${i}`, + async () => source(), + new AbortController().signal, + ); + } + expect(thumbnails.cachedVideoThumbnail("clip:0")).toBeNull(); + expect(thumbnails.cachedVideoThumbnail("clip:32")).toBe(frame); + expect(mocks.createPlayer).toHaveBeenCalledTimes(33); + expect( + await thumbnails.loadVideoThumbnail( + "clip:0", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + expect(mocks.createPlayer).toHaveBeenCalledTimes(34); + }); +}); diff --git a/apps/mobile/src/lib/videoThumbnails.ts b/apps/mobile/src/lib/videoThumbnails.ts new file mode 100644 index 000000000000..927e1174a7f2 --- /dev/null +++ b/apps/mobile/src/lib/videoThumbnails.ts @@ -0,0 +1,81 @@ +import type { VideoThumbnail } from "expo-video"; + +import type { AttachmentPreviewFile } from "./attachmentDownload"; + +const thumbnails = new Map(); +const MAX_CACHED_THUMBNAILS = 32; +let pending: Promise = Promise.resolve(); + +export function cachedVideoThumbnail(key: string): VideoThumbnail | null { + return thumbnails.get(key) ?? null; +} + +async function extractFrame(uri: string, signal: AbortSignal) { + const { createVideoPlayer } = await import("expo-video"); + if (signal.aborted) return null; + const player = createVideoPlayer(null); + let disposed = false; + let cancel = () => {}; + let timeout: ReturnType | undefined; + try { + // Never play or change audio settings: thumbnails must leave the shared audio session alone. + player.bufferOptions = { preferredForwardBufferDuration: 1 }; + const canceled = new Promise((resolve) => { + cancel = () => resolve(null); + }); + signal.addEventListener("abort", cancel, { once: true }); + // An unreachable environment must not hold up thumbnails for other environments. + timeout = setTimeout(cancel, 15_000); + const frame = (async () => { + await player.replaceAsync({ uri, contentType: "progressive" }); + if (disposed || signal.aborted) return null; + const [thumbnail] = await player.generateThumbnailsAsync([0], { + maxWidth: 480, + maxHeight: 480, + }); + return thumbnail ?? null; + })(); + return await Promise.race([frame, canceled]); + } finally { + disposed = true; + clearTimeout(timeout); + signal.removeEventListener("abort", cancel); + player.release(); + } +} + +/** Serializes frame extraction and releases each temporary player and local-file lease. */ +export function loadVideoThumbnail( + key: string, + resolveSource: ( + signal: AbortSignal, + ) => Promise | null>, + signal: AbortSignal, +): Promise { + if (signal.aborted) return Promise.resolve(null); + const cached = cachedVideoThumbnail(key); + if (cached) return Promise.resolve(cached); + const load = pending + .then(async () => { + if (signal.aborted) return null; + const cached = cachedVideoThumbnail(key); + if (cached) return cached; + + const source = await resolveSource(signal); + if (!source) return null; + try { + const thumbnail = await extractFrame(source.uri, signal); + if (!thumbnail || signal.aborted) return null; + thumbnails.set(key, thumbnail); + if (thumbnails.size > MAX_CACHED_THUMBNAILS) { + thumbnails.delete(thumbnails.keys().next().value!); + } + return thumbnail; + } finally { + source.dispose(); + } + }) + .catch(() => null); + pending = load; + return load; +} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 3ab0baa39046..90b1ad262326 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -134,6 +134,7 @@ import { releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, + retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, setComposerDraftText, setStickyComposerModelSelection, @@ -319,6 +320,82 @@ describe("mobile composer drafts", () => { expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); }); + it("keeps a removed file until both playback and a share copy finish", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; + const file = { + id: "file-preview", + type: "file" as const, + name: "recording.mp4", + mimeType: "video/mp4", + sizeBytes: 42, + fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, + }; + const currentFile = { + ...file, + fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, + }; + const releasePlayback = retainComposerAttachmentFileForPreview(file); + const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); + onTestFinished(releasePlayback); + onTestFinished(releaseShareCopy); + + await releaseUnusedComposerAttachmentFiles([currentFile]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + releasePlayback(); + releasePlayback(); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + const deleted = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + deleted.resolve(); + return undefined; + }); + releaseShareCopy(); + await deleted.promise; + + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); + }); + + it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const file = { + id: "file-opening-preview", + type: "file" as const, + name: "recording.mp4", + mimeType: "video/mp4", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/recording.mp4", + }; + const ownershipReadStarted = Promise.withResolvers(); + const ownershipRead = Promise.withResolvers<[]>(); + incomingShareStorageMocks.load.mockImplementationOnce(() => { + ownershipReadStarted.resolve(); + return ownershipRead.promise; + }); + + const cleanup = releaseUnusedComposerAttachmentFiles([file]); + await ownershipReadStarted.promise; + const release = retainComposerAttachmentFileForPreview(file); + onTestFinished(release); + ownershipRead.resolve([]); + await cleanup; + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + const deleted = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + deleted.resolve(); + return undefined; + }); + release(); + await deleted.promise; + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]); + }); + it("removes an unreferenced local file and its pending upload", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7d243360f044..28f3b9f7f99e 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -15,8 +15,12 @@ import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; -import { composerAttachmentFileReferenceKey } from "../lib/composerAttachmentFiles"; -import type { DraftComposerAttachment } from "../lib/composerImages"; +import { + composerAttachmentFileReferenceKey, + isComposerAttachmentFileRetained, + retainComposerAttachmentFile, +} from "../lib/composerAttachmentFiles"; +import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; @@ -287,6 +291,9 @@ export async function flushComposerDrafts(): Promise { } function isComposerAttachmentFileReferenced(fileUri: string): boolean { + if (isComposerAttachmentFileRetained(fileUri)) { + return true; + } const referenceKey = composerAttachmentFileReferenceKey(fileUri); const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); const queuedMessages = Object.values( @@ -436,6 +443,15 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } +/** Keeps previews usable after send/removal, then retries the normal ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: DraftComposerFileAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + scheduleUnusedComposerAttachmentCleanup([attachment]); + }); +} + function schedulePersistComposerState(): void { if (persistTimer !== null) { clearTimeout(persistTimer); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9ee36ffebddf..7ae036bdc99e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,13 +1,102 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 { HttpServerResponse } from "effect/unstable/http"; import { assetResponseHeaders, + assetFileResponse, downloadContentDisposition, isLoopbackHostname, resolveDevRedirectUrl, } from "./http.ts"; +const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); + +describe("video asset byte ranges", () => { + it.effect("streams exactly the requested bytes and leaves full downloads intact", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); + const file = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(file, "0123456789"); + const asset = { path: file, mimeType: "video/mp4" }; + for (const [header, expected, contentRange] of [ + ["bytes=0-1", "01", "bytes 0-1/10"], + ["bytes=4-", "456789", "bytes 4-9/10"], + ["bytes=-3", "789", "bytes 7-9/10"], + ["bytes=-999999999999999999999999", "0123456789", "bytes 0-9/10"], + ["bytes=8-999999999999999999999999", "89", "bytes 8-9/10"], + ] as const) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); + expect(response.status).toBe(206); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe(contentRange); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + for (const header of [ + undefined, + "items=0-1", + "bytes=0-1,4-5", + "bytes=8-2", + "bytes=-", + "bytes=bad", + ]) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe("0123456789"); + } + const conditional = HttpServerResponse.toWeb( + yield* assetFileResponse(asset, "bytes=0-1", '"old-etag"'), + ); + expect(conditional.status).toBe(200); + expect(yield* Effect.promise(() => conditional.text())).toBe("0123456789"); + const uppercase = HttpServerResponse.toWeb( + yield* assetFileResponse({ ...asset, mimeType: "Video/MP4" }, "bytes=0-1"), + ); + expect(uppercase.status).toBe(206); + expect(yield* Effect.promise(() => uppercase.text())).toBe("01"); + const image = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "image/png" }, "bytes=0-1"), + ); + expect(image.status).toBe(200); + expect(image.headers.has("accept-ranges")).toBe(false); + expect(yield* Effect.promise(() => image.text())).toBe("0123456789"); + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("rejects ranges outside the file, including empty files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); + const file = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(file, "0123456789"); + for (const header of ["bytes=10-", "bytes=-0", "bytes=999999999999999999999999-"]) { + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, header), + ); + expect(response.status).toBe(416); + expect(response.headers.get("content-range")).toBe("bytes */10"); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } + yield* fs.writeFileString(file, ""); + const empty = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, "bytes=0-1"), + ); + expect(empty.status).toBe(416); + expect(empty.headers.get("content-range")).toBe("bytes */0"); + }).pipe(Effect.provide(fileResponseLayer)), + ); +}); + describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { expect(isLoopbackHostname("127.0.0.1")).toBe(true); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 5e3f46711b18..b83461775e91 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -112,6 +112,63 @@ export function assetResponseHeaders( }; } +/** A single byte range for native video readers; unsupported range syntax uses the full file. */ +function assetByteRange(header: string, size: bigint) { + const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim()); + if (!match || (!match[1] && !match[2])) return null; + const first = match[1] ? BigInt(match[1]) : null; + const last = match[2] ? BigInt(match[2]) : null; + if (first !== null && last !== null && last < first) return null; + if (size === 0n || (first !== null && first >= size) || (first === null && last === 0n)) { + return { _tag: "Unsatisfiable" as const }; + } + const start = first ?? (last! >= size ? 0n : size - last!); + const end = first === null || last === null || last >= size ? size - 1n : last; + return { + _tag: "Range" as const, + offset: start, + bytesToRead: end - start + 1n, + contentRange: `bytes ${start}-${end}/${size}`, + }; +} + +export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( + asset: { + readonly path: string; + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + }, + rangeHeader?: string, + ifRangeHeader?: string, +) { + const headers = assetResponseHeaders(asset.path, asset); + if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { + headers["Accept-Ranges"] = "bytes"; + // If-Range requires a matching validator. A full response is safe when we cannot validate it. + if (rangeHeader && !ifRangeHeader) { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(asset.path); + const range = assetByteRange(rangeHeader, info.size); + if (range?._tag === "Unsatisfiable") { + return HttpServerResponse.empty({ + status: 416, + headers: { ...headers, "Content-Range": `bytes */${info.size}` }, + }); + } + if (range?._tag === "Range") { + return yield* HttpServerResponse.file(asset.path, { + status: 206, + offset: range.offset, + bytesToRead: range.bytesToRead, + headers: { ...headers, "Content-Range": range.contentRange }, + }); + } + } + } + return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); +}); + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -277,19 +334,11 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(asset.path, { - status: 200, - headers: assetResponseHeaders( - asset.path, - asset.download || asset.mimeType !== undefined - ? { - ...(asset.download ? { download: true } : {}), - ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}), - ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}), - } - : undefined, - ), - }).pipe( + return yield* assetFileResponse( + asset, + request.method === "GET" ? request.headers.range : undefined, + request.headers["if-range"], + ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); }), diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index f5727980a251..8638cade733a 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -17,6 +17,9 @@ import type { EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { videoMimeType } from "@t3tools/shared/video"; + +export { videoMimeType } from "@t3tools/shared/video"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -59,27 +62,6 @@ export function isFileAttachment(attachment: ChatAttachment): attachment is Chat return attachment.type === "file"; } -const VIDEO_MIME_TYPE_BY_EXTENSION: Readonly> = { - avi: "video/x-msvideo", - m4v: "video/mp4", - mkv: "video/x-matroska", - mov: "video/quicktime", - mp4: "video/mp4", - ogv: "video/ogg", - webm: "video/webm", -}; - -export function videoMimeType( - attachment: Pick, -): string | null { - const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; - if (mimeType.startsWith("video/")) return mimeType; - const dotIndex = attachment.name.lastIndexOf("."); - return dotIndex < 0 - ? null - : (VIDEO_MIME_TYPE_BY_EXTENSION[attachment.name.slice(dotIndex + 1).toLowerCase()] ?? null); -} - export function isVideoAttachment(attachment: ChatFileAttachment): boolean { return videoMimeType(attachment) !== null; } diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index 86c61921ffcf..ebc19d82d6da 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -1,4 +1,4 @@ -# Mobile navigation headers +# Mobile navigation The iOS Home and thread routes share the root native stack in [`Stack.tsx`](../../apps/mobile/src/Stack.tsx). Keeping them in one navigation @@ -48,3 +48,59 @@ iOS `MenuView` action, including nested actions. The menu library's Fabric bridg rendering `MenuView` directly. Explicit colors are preserved, and destructive actions default to the theme's danger foreground color. Native stack header menus use a separate implementation and do not need this workaround. + +## Native media presentations + +`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's +full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child +and pass the stable identifier to the presentation. The registry keeps weak +references to source views; recycled or compact composer thumbnails can register +the same identifier. Identifiers must distinguish simultaneously visible attachments. +This source registration does not own playback and can be reused by a future image +viewer. Android uses a regular view. + +On iOS, video previews mount `AVPlayerViewController` temporarily inside the +registered source and enter full screen through AVKit. AVKit +owns that zoom, its playback controls, Close button, and interactive dismissal. +Do not replace AVKit's transition with `preferredTransition`: in the iOS 27 +simulator, that leaves native Close unable to exit full screen. When the source is unavailable, +the player uses a standard modal presentation. Programmatic entry uses the same +guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; +if that selector is unavailable, the player also falls back to a standard modal. +Images retain their existing viewer. + +Received videos open directly from their signed asset URL. AVKit handles buffering; +the client does not download the entire file or show a separate opening overlay before +presentation. The URL is captured once per preview so credential refresh does not +restart playback. Saving or sharing still downloads the original file. + +The native presentation promise completes after dismissal. Local draft previews +hold their file lease until that promise settles. The iOS preview component requests +native dismissal when its source screen unmounts. Playback pauses in the background. +AVPlayer activates audio as playback starts. The presenter pauses and releases +its own player on close, then restores the previous audio-session configuration +if no other component changed it during playback. It does not deactivate the +shared session, which may still serve another player or recorder. Android retains +its React Native modal and Expo Video player. + +`shareFileFromSource` uses the same source registration to anchor UIKit's activity +controller. Its promise completes when the native share flow finishes, keeping +the existing attachment lease and foreground handoff active for that duration. +Android uses Expo Sharing. On iOS, received and draft video attachments expose +Save or share through `VideoAttachmentMenu`. The attachment supplies the source +identifier, and the native share presentation inherits its appearance. AVKit's +iOS playback controls do not expose a public custom-share-action API. + +Video attachment thumbnails use Expo Video's native frame extraction and Expo Image. +Received attachments use their signed asset URL; drafts retain and resolve their local file +until extraction ends. Extraction is serial; temporary players never play or change audio settings. Leaving the screen cancels +pending work; a 15-second limit prevents an unreachable source from holding up the queue. +The client keeps at most 32 native images, each bounded to 480 pixels per side, keyed by +environment and attachment identity rather than expiring URLs. Images still displayed keep +their own references when evicted from that cache. + +The asset HTTP route supports single byte ranges for videos so iOS can read metadata and +frames without first downloading the whole file. Normal downloads keep their full response; +unsupported ranges and conditional `If-Range` requests also fall back to the full file. +An older environment without range support may still show the play-card fallback. Thumbnail +failure never disables playback or sharing. diff --git a/docs/user/composer.md b/docs/user/composer.md index 6f14245ae27c..62db0b073ffd 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -22,8 +22,15 @@ T3 Code from other apps through the system share sheet. Mobile uploads happen wh sends, so queued messages keep their files until they deliver. Select a received file on mobile to save it or open it in another app through the system share sheet. -On web and desktop, select a video attachment before or after sending to play it with the browser's -built-in controls. Playback depends on the video formats and codecs that the browser supports. +Select a video attachment before or after sending to play it. Web and desktop use the browser's +built-in controls. On mobile, videos open in a full-screen player with native playback controls. +Supported videos show a thumbnail in the conversation and composer. +On iOS, received videos stream from their environment as they play. Supported formats and codecs +depend on the browser or device; you can save an unsupported video to open it in another app. + +On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the +conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open +the system share options. On Android, use **Save or share video** inside the preview. On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach again** next to it. Attach the file again or remove it, then send. diff --git a/packages/shared/package.json b/packages/shared/package.json index eeaa5f59e087..aeba7e602a86 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -199,6 +199,10 @@ "types": "./src/filePreview.ts", "import": "./src/filePreview.ts" }, + "./video": { + "types": "./src/video.ts", + "import": "./src/video.ts" + }, "./chatList": { "types": "./src/chatList.ts", "import": "./src/chatList.ts" diff --git a/packages/shared/src/video.test.ts b/packages/shared/src/video.test.ts new file mode 100644 index 000000000000..b550033e2987 --- /dev/null +++ b/packages/shared/src/video.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { videoMimeType } from "./video.ts"; + +describe("videoMimeType", () => { + it("recognizes a saved video with a generic picker MIME type", () => { + expect(videoMimeType({ name: "Recording.MOV", mimeType: "application/octet-stream" })).toBe( + "video/quicktime", + ); + }); + + it("keeps an explicit video MIME type authoritative and removes parameters", () => { + expect(videoMimeType({ name: "recording.mp4", mimeType: " VIDEO/WebM; codecs=vp9 " })).toBe( + "video/webm", + ); + }); + + it.each(["README", "report.pdf", "file.constructor", "file.__proto__"])( + "does not mistake %s for a video", + (name) => { + expect(videoMimeType({ name, mimeType: "application/octet-stream" })).toBeNull(); + }, + ); +}); diff --git a/packages/shared/src/video.ts b/packages/shared/src/video.ts new file mode 100644 index 000000000000..634bc29ed6d3 --- /dev/null +++ b/packages/shared/src/video.ts @@ -0,0 +1,22 @@ +const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([ + ["avi", "video/x-msvideo"], + ["m4v", "video/mp4"], + ["mkv", "video/x-matroska"], + ["mov", "video/quicktime"], + ["mp4", "video/mp4"], + ["ogv", "video/ogg"], + ["webm", "video/webm"], +]); + +/** Recognizes videos even when the file picker omitted their MIME type. */ +export function videoMimeType(attachment: { + readonly name: string; + readonly mimeType: string; +}): string | null { + const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + if (mimeType.startsWith("video/")) return mimeType; + const dotIndex = attachment.name.lastIndexOf("."); + return dotIndex < 0 + ? null + : (VIDEO_MIME_TYPE_BY_EXTENSION.get(attachment.name.slice(dotIndex + 1).toLowerCase()) ?? null); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54e0b1218593..4a1a2067a831 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,6 +331,9 @@ importers: expo-device: specifier: ~57.0.1 version: 57.0.1(expo@57.0.18) + expo-document-picker: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.18) expo-file-system: specifier: ~57.0.6 version: 57.0.6(expo@57.0.18)(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)) @@ -382,6 +385,9 @@ importers: expo-updates: specifier: ~57.0.19 version: 57.0.19(expo-dev-client@57.0.16(expo@57.0.18)(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)))(expo@57.0.18)(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))(react@19.2.3) + expo-video: + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.18)(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))(react@19.2.3) expo-web-browser: specifier: ~57.0.2 version: 57.0.2(expo@57.0.18)(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)) @@ -6562,6 +6568,11 @@ packages: peerDependencies: expo: '*' + expo-document-picker@57.0.1: + resolution: {integrity: sha512-qBwM5oxDZ3I9kwFD3pUE1oK/WNv9artoEKO6UpqhQgNRr0XA1ALRVWYjkF4+ge9lUNDRehjTm/jenINkzqg84g==} + peerDependencies: + expo: '*' + expo-eas-client@57.0.2: resolution: {integrity: sha512-EfFiqUr0o9TvTOgMbqDiV1oIdG/d7kirhqtwa7roGmm9wF+CpXUz20d9YGzO6KzJWUYgdUgu4B6Ocv0jNJUdnQ==} @@ -6731,6 +6742,13 @@ packages: expo-dev-client: optional: true + expo-video@57.0.3: + resolution: {integrity: sha512-Z+rLdBSzICwoHm/HUxND5fm5nfgqiB+QPWAOKxmA8ScYwvxG1UGjZ6OaayvPc3GkT4aucsbycmFO0uUv/qnIhg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-web-browser@57.0.2: resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} peerDependencies: @@ -16434,6 +16452,10 @@ snapshots: expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) ua-parser-js: 0.7.41 + expo-document-picker@57.0.1(expo@57.0.18): + dependencies: + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-eas-client@57.0.2: {} expo-file-system@57.0.6(expo@57.0.18)(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)): @@ -16674,6 +16696,12 @@ snapshots: transitivePeerDependencies: - supports-color + expo-video@57.0.3(expo@57.0.18)(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))(react@19.2.3): + dependencies: + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + react: 19.2.3 + 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) + expo-web-browser@57.0.2(expo@57.0.18)(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)): dependencies: expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) From f47e74004af232f0e3df8dc10093601d1c2c3ea3 Mon Sep 17 00:00:00 2001 From: Matthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:59:31 -0400 Subject: [PATCH 13/42] fix(web): prevent chat metadata overlap (#8851) --- apps/web/src/components/BranchToolbar.tsx | 12 ++++++---- .../BranchToolbarEnvModeSelector.tsx | 23 +++++++++++-------- apps/web/src/components/chat/ChatHeader.tsx | 11 +++++---- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 8a4dc5819923..b0b1440587ea 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -265,8 +265,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { let needed = 0; let groups = 0; for (const child of current.children) { - if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; - needed += contentWidth(child); + if (!(child instanceof HTMLElement)) continue; + const width = contentWidth(child); + if (width <= 1) continue; + needed += width; groups += 1; } needed += stripGap * Math.max(0, groups - 1); @@ -356,7 +358,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { // Label widths can change without the strip box moving (font family or // size preferences), so re-measure on every render as well as on resize // and font loads. - useEffect(() => { + useLayoutEffect(() => { measure(); }); @@ -487,7 +489,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onUsePreviousWorktree={onUsePreviousWorktree} /> ) : ( -
+
{showEnvironmentIndicator && availableEnvironments && ( <> {activeWorktreePath ? ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + ) : ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + )} + + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + + ); } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dbba327489ac..c9c733e10f19 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -288,13 +288,16 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > - + {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProjectName ? ( <> - + } > @@ -320,7 +323,7 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - + {renamingTitle !== null ? ( Date: Tue, 1 Sep 2026 00:59:39 +0200 Subject: [PATCH 14/42] fix(server): preserve usage cache outside walked roots (#8540) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/server/src/usage/usageScanCache.test.ts | 14 ++++++++++++++ apps/server/src/usage/usageScanCache.ts | 13 ++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 24fc5376cbc8..8c6faa88a263 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -193,6 +193,20 @@ describe("pruneScanCache with an unwalked root", () => { expect(removed).toBe(0); expect(cache.size).toBe(1); }); + + it("keeps entries under a sibling path that only shares the walked root prefix", () => { + const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); }); describe("dedupeWithinFile", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 10c97e49e15f..bca97152c1d6 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,6 +14,9 @@ * * @module usageScanCache */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + import type { UsageProviderKind } from "@t3tools/contracts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -229,7 +232,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number let removed = 0; for (const [path, entry] of cache) { const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const underWalkedRoot = options.walkedRoots.some((root) => { + const relative = NodePath.relative(root, path); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${NodePath.sep}`) && + !NodePath.isAbsolute(relative)) + ); + }); const deleted = underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); if (agedOut || deleted) { From f8e4accf27415eb9f7f4106087d2a1b0186d66b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 16:23:40 -0700 Subject: [PATCH 15/42] feat(mobile): add native image and PDF previews (#8959) Co-authored-by: Julius Marminge --- apps/mobile/app.config.ts | 1 + .../ios/T3NativeControlsModule.swift | 36 ++- .../ios/T3NativeFilePresentation.swift | 165 +++++++++++++ .../components/ComposerAttachmentStrip.tsx | 103 +++++--- .../mobile/src/components/FilePreview.ios.tsx | 43 ++++ apps/mobile/src/components/FilePreview.tsx | 52 ++++ .../src/components/FilePreviewModal.tsx | 93 +++++++ .../src/components/VideoPreviewModal.ios.tsx | 4 +- .../src/components/VideoPreviewModal.tsx | 4 +- .../src/components/VideoThumbnailImage.tsx | 4 +- .../features/files/ThreadFilesRouteScreen.tsx | 31 ++- .../files/WorkspaceFileImagePreview.tsx | 49 ++-- .../review/ReviewCommentComposerSheet.tsx | 15 +- .../features/threads/NewTaskDraftScreen.tsx | 26 +- .../src/features/threads/ThreadComposer.tsx | 27 +- .../src/features/threads/ThreadFeed.tsx | 231 ++++++++++++------ apps/mobile/src/lib/composerFiles.test.ts | 112 +++++++++ apps/mobile/src/lib/composerImages.ts | 48 +++- apps/mobile/src/lib/filePreview.test.ts | 17 ++ apps/mobile/src/lib/filePreview.ts | 6 + ...test.ts => localAttachmentPreview.test.ts} | 33 ++- ...eoPreview.ts => localAttachmentPreview.ts} | 6 +- docs/internals/mobile-navigation.md | 27 +- docs/user/composer.md | 11 +- 24 files changed, 933 insertions(+), 211 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift create mode 100644 apps/mobile/src/components/FilePreview.ios.tsx create mode 100644 apps/mobile/src/components/FilePreview.tsx create mode 100644 apps/mobile/src/components/FilePreviewModal.tsx create mode 100644 apps/mobile/src/lib/filePreview.test.ts create mode 100644 apps/mobile/src/lib/filePreview.ts rename apps/mobile/src/lib/{localVideoPreview.test.ts => localAttachmentPreview.test.ts} (72%) rename apps/mobile/src/lib/{localVideoPreview.ts => localAttachmentPreview.ts} (88%) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 8bfc151487a3..c4a7717cd46a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -206,6 +206,7 @@ const config: ExpoConfig = { }, NSLocalNetworkUsageDescription: "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", + NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.", ITSAppUsesNonExemptEncryption: false, // The App Store screenshot harness rotates the iPad interface from // inside the app (CI denies osascript the Accessibility access that diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 4f9073860723..ddc8a80270fa 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -5,6 +5,7 @@ import UIKit public final class T3NativeControlsModule: Module { private let presentationSources = T3PresentationSources() private var videoPresentation: T3NativeVideoPresentation? + private var filePresentation: T3NativeFilePresentation? public func definition() -> ModuleDefinition { Name("T3NativeControls") @@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module { self.dismissVideo(identifier: identifier) }.runOnQueue(.main) + AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in + try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, + identifier: identifier, promise: promise) + }.runOnQueue(.main) + + AsyncFunction("dismissFile") { (identifier: String) in + self.dismissFile(identifier: identifier) + }.runOnQueue(.main) + OnDestroy { let presentation = self.videoPresentation - DispatchQueue.main.async { presentation?.dismiss() } + let file = self.filePresentation + DispatchQueue.main.async { + presentation?.dismiss() + file?.dismiss() + } } View(T3PresentationSourceView.self) { @@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module { let isPlayableURL = url.isFileURL ? FileManager.default.isReadableFile(atPath: url.path) : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) - guard videoPresentation == nil, + guard videoPresentation == nil, filePresentation == nil, let presenter = appContext?.utilities?.currentViewController(), isPlayableURL else { @@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module { if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } } + private func presentFile(url: URL, title: String, sourceIdentifier: String, + identifier: String, promise: Promise) throws { + guard filePresentation == nil, videoPresentation == nil, + let presenter = appContext?.utilities?.currentViewController() + else { throw URLError(.cannotLoadFromNetwork) } + let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources, + sourceIdentifier: sourceIdentifier) { [weak self] error in + self?.filePresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + filePresentation = file + file.present(url: url, title: title, from: presenter) + } + + private func dismissFile(identifier: String) { + if filePresentation?.identifier == identifier { filePresentation?.dismiss() } + } + private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { guard let presenter = appContext?.utilities?.currentViewController() else { throw NSError( diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift new file mode 100644 index 000000000000..1a7009c3821d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -0,0 +1,165 @@ +import ImageIO +import QuickLook +import UIKit +import UniformTypeIdentifiers + +private final class FilePreviewItem: NSObject, QLPreviewItem { + var previewItemURL: URL? + var previewItemTitle: String? +} + +private final class FilePreviewController: QLPreviewController { + var onAppear: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + onAppear?() + } +} + +/// Quick Look owns image and document controls, zooming, and source-view transitions. +final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, + QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate { + let identifier: String + private var controller: UIViewController? + private let completion: (Error?) -> Void + private weak var sources: T3PresentationSources? + private let sourceIdentifier: String + private let item = FilePreviewItem() + private var loading: Task? + private var dismissRequested = false + private var finished = false + + init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) { + self.identifier = identifier + self.sources = sources + self.sourceIdentifier = sourceIdentifier + self.completion = completion + super.init() + } + + func present(url: URL, title: String, from presenter: UIViewController) { + loading = Task { @MainActor [self] in + do { + let file = try await Self.prepareFile(url: url, title: title) + guard !finished, !Task.isCancelled else { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + return + } + item.previewItemURL = file + item.previewItemTitle = title + let preview = FilePreviewController() + preview.delegate = self + preview.dataSource = self + preview.onAppear = { [weak self] in self?.resumePendingDismissal() } + controller = preview + presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in + resumePendingDismissal() + } + preview.presentationController?.delegate = self + } catch { + finish(error: error) + } + } + } + + func dismiss() { + dismissRequested = true + loading?.cancel() + guard !finished else { return } + guard let controller else { finish(); return } + // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal. + // Starting a second modal transition while UIKit is settling the first can strand it. + guard !controller.isBeingPresented, !controller.isBeingDismissed else { return } + controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() } + } + + private func resumePendingDismissal() { + // Appearance callbacks run before UIKit has cleared the current transition. + DispatchQueue.main.async { [weak self] in + if self?.dismissRequested == true { self?.dismiss() } + } + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + item + } + + func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? { + guard !UIAccessibility.isReduceMotionEnabled else { return nil } + return sources?.view(for: sourceIdentifier) + } + + func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem, + inSourceView view: AutoreleasingUnsafeMutablePointer) -> CGRect { + guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero } + view.pointee = source + return source.bounds + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() } + + private func finish(error: Error? = nil) { + guard !finished else { return } + finished = true + loading?.cancel() + loading = nil + if let file = item.previewItemURL { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + } + item.previewItemURL = nil + DispatchQueue.main.async { [completion] in completion(error) } + } + + /// Copy original bytes so preview and sharing do not mutate a draft or workspace file. + nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL { + try Task.checkCancellation() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + let download = directory.appendingPathComponent("original") + if url.isFileURL { + try FileManager.default.copyItem(at: url, to: download) + } else if url.scheme == "data" { + try Data(contentsOf: url).write(to: download, options: .atomic) + } else { + guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { + throw URLError(.unsupportedURL) + } + let (temporaryFile, response) = try await URLSession.shared.download(from: url) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { + throw URLError(.badServerResponse) + } + try FileManager.default.moveItem(at: temporaryFile, to: download) + } + try Task.checkCancellation() + let type: UTType + if let image = CGImageSourceCreateWithURL(download as CFURL, nil), + CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), + let detectedType = UTType(imageType as String) { + type = detectedType + } else if CGPDFDocument(download as CFURL) != nil { + type = .pdf + } else { + throw URLError(.cannotDecodeContentData) + } + let filename = URL(fileURLWithPath: title).lastPathComponent as NSString + let originalExtension = filename.pathExtension + let fileExtension = UTType(filenameExtension: originalExtension) == type + ? originalExtension : type.preferredFilenameExtension ?? "png" + let stem = filename.deletingPathExtension + var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") + while name.utf8.count > 200 { name.removeLast() } + let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)") + try FileManager.default.moveItem(at: download, to: file) + return file + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } +} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index b18777c02260..96640f195836 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -6,15 +6,18 @@ import { Alert, Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { PresentationSource } from "./NativePresentation"; +import type { FilePreviewSource } from "./FilePreviewModal"; +import { isPdfFile } from "../lib/filePreview"; export interface ComposerAttachmentStripProps { /** Attachments to display. */ readonly attachments: ReadonlyArray; /** Called when the user removes an attachment. */ readonly onRemove: (imageId: string) => void; - /** Called when the user taps on an image thumbnail to preview it. */ - readonly onPressImage?: (previewUri: string) => void; + /** Called when the user taps an image or PDF to preview it. */ + readonly onPressPreview?: (source: FilePreviewSource) => void; readonly onPressVideo?: ( attachment: DraftComposerFileAttachment, sourceIdentifier: string, @@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: { readonly size: number; readonly borderRadius: number; readonly compact?: boolean; - readonly onPressImage?: (previewUri: string) => void; + readonly onPressPreview?: (source: FilePreviewSource) => void; readonly onPressVideo?: ( attachment: DraftComposerFileAttachment, sourceIdentifier: string, @@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: { const { attachment } = props; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; if (attachment.type === "image") { + const sourceIdentifier = `draft-image:${attachment.id}`; return ( - props.onPressImage?.(attachment.previewUri) : undefined} - > - - + + + props.onPressPreview?.({ + kind: "image", + uri: attachment.dataUrl, + name: attachment.name, + sourceIdentifier, + }) + } + > + + + ); } const onPressVideo = props.onPressVideo; @@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: { ); } + const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined; + const sourceIdentifier = `draft-file:${attachment.id}`; return ( - - - {!props.compact ? ( - - {attachment.name} - - ) : null} - + + + props.onPressPreview?.({ + kind: "pdf", + name: attachment.name, + attachment, + sourceIdentifier, + }) + } + className={ + props.compact + ? "items-center justify-center bg-subtle" + : "items-center justify-center gap-1 bg-subtle px-2" + } + style={style} + > + + {!props.compact ? ( + + {attachment.name} + + ) : null} + + ); } @@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: { shareRef.current = controller; setSharing(true); void (async () => { - const preview = await loadLocalVideoPreview(attachment, controller.signal); + const preview = await loadLocalAttachmentPreview(attachment, controller.signal); if (!preview) return; try { await preview.share(controller.signal, sourceIdentifier); @@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { attachment={attachment} size={size} borderRadius={radius} - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} onPressVideo={props.onPressVideo} /> ; + dismissFile(identifier: string): Promise; +}>("T3NativeControls"); + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name, sourceIdentifier } = props.source; + const identifier = useId(); + const onRequestClose = useEffectEvent(props.onRequestClose); + + useEffect(() => { + let canceled = false; + void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier) + .catch(() => { + if (!canceled) { + Alert.alert("Could not open preview", "The file could not be loaded. Please try again."); + } + }) + .finally(() => { + if (!canceled) onRequestClose(); + }); + return () => { + canceled = true; + void NativeControls.dismissFile(identifier).catch(() => undefined); + }; + }, [uri, name, sourceIdentifier, identifier]); + + return null; +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx new file mode 100644 index 000000000000..f10bfb8b3e63 --- /dev/null +++ b/apps/mobile/src/components/FilePreview.tsx @@ -0,0 +1,52 @@ +import { useEffect, useEffectEvent } from "react"; +import { Alert } from "react-native"; +import ImageViewing from "react-native-image-viewing"; + +import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; + +function PdfPreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name } = props.source; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + const controller = new AbortController(); + const input = { + attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" }, + signal: controller.signal, + }; + // Android's system chooser supplies the installed PDF apps. + const opened = + uri.startsWith("file:") || uri.startsWith("content:") + ? shareLocalAttachment({ ...input, uri }) + : downloadAndShareAttachment({ ...input, url: uri }); + void opened + .catch(() => { + if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again."); + }) + .finally(() => { + if (!controller.signal.aborted) onRequestClose(); + }); + return () => controller.abort(); + }, [uri, name]); + return null; +} + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + if (props.source.kind === "pdf") return ; + return ( + + ); +} diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx new file mode 100644 index 000000000000..c9df7e892c27 --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -0,0 +1,93 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useState } from "react"; +import { Alert, Keyboard } from "react-native"; + +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { FilePreview } from "./FilePreview"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf"; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: DraftComposerFileAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); + +function ResolvedFilePreview(props: { + readonly source: FilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer. + const [uri, setUri] = useState("uri" in source ? source.uri : null); + const onRequestClose = useEffectEvent(props.onRequestClose); + const failed = + environmentId !== null && + uri === null && + (connection._tag === "None" || asset._tag === "Failure"); + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (uri === null && asset._tag === "Success") setUri(asset.url); + }, [uri, asset]); + useEffect(() => { + if (!failed) return; + Alert.alert("Could not open preview", "Reconnect to this environment and try again."); + onRequestClose(); + }, [failed]); + useEffect(() => { + if (!("attachment" in source)) return; + const controller = new AbortController(); + let release: (() => void) | undefined; + void loadLocalAttachmentPreview(source.attachment, controller.signal) + .then((file) => { + if (!file) return; + if (controller.signal.aborted) { + file.dispose(); + return; + } + release = file.dispose; + setUri(file.uri); + }) + .catch(() => { + if (controller.signal.aborted) return; + Alert.alert("Could not open preview", "Attach the file again and retry."); + onRequestClose(); + }); + return () => { + controller.abort(); + release?.(); + }; + }, [source]); + + return uri === null ? null : ( + + ); +} + +export function FilePreviewModal(props: { + readonly source: FilePreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource]); + + if (!props.source || !isFocused) return null; + return ; +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index 4b2cfe27e6ff..a947d8d2e51c 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -4,7 +4,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import type { VideoPreviewSource } from "./VideoPreviewModal"; @@ -67,7 +67,7 @@ function NativeVideoPreview(props: { void (async () => { const file = source.type === "local" - ? await loadLocalVideoPreview(source.attachment, controller.signal) + ? await loadLocalAttachmentPreview(source.attachment, controller.signal) : null; if (source.type === "local" && !file) return; try { diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 190f5c7b01bb..eaa01c5d1714 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -20,7 +20,7 @@ import { type AttachmentPreviewFile, } from "../lib/attachmentDownload"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { SymbolView } from "./AppSymbol"; @@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: { setFailure(null); const loading = source.type === "local" - ? loadLocalVideoPreview(source.attachment, controller.signal) + ? loadLocalAttachmentPreview(source.attachment, controller.signal) : downloadAttachmentForPreview({ url: downloadUrl!, attachment: { name: attachment.name, mimeType }, diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index 5af33499e4d1..0be94c700ce6 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import { StyleSheet } from "react-native"; import type { DraftComposerFileAttachment } from "../lib/composerImages"; -import { loadLocalVideoPreview } from "../lib/localVideoPreview"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; export function VideoThumbnailImage(props: { @@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: { async (signal) => typeof source === "string" ? { uri: source, dispose: () => undefined } - : loadLocalVideoPreview(source, signal), + : loadLocalAttachmentPreview(source, signal), controller.signal, ).then((thumbnail) => { if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 462d075324dd..5dddac1dd820 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadSelection } from "../../state/use-thread-selection"; @@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly mode: FileViewMode; } | null>(null); const [previewRevision, setPreviewRevision] = useState(0); + const [fullScreenPreview, setFullScreenPreview] = useState(null); const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); const canPreview = @@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { inline: false, onPress: () => copyTextWithHaptic(relativePath), } as const, + isPdfFile({ name: relativePath }) && previewUri !== null + ? ({ + id: "open-pdf", + title: "Open PDF", + icon: "arrow.up.left.and.arrow.down.right", + inline: false, + onPress: () => + setFullScreenPreview({ + kind: "pdf", + uri: previewUri, + name: basename(relativePath), + }), + } as const) + : null, isBrowserFile && typeof assetPreviewUri === "string" ? ({ id: "open-browser", @@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { } as const) : null, ].filter((action) => action !== null); - }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]); + }, [ + assetPreviewUri, + previewUri, + canPreview, + isBrowserFile, + isImageFile, + relativePath, + resolvedActiveMode, + ]); const androidFileMenuActions = useMemo( () => @@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { truncated={fileData?.truncated ?? false} onRefresh={() => fileQuery.refresh()} /> + setFullScreenPreview(null)} + /> ); diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 73eca66bf999..e725c4d133f6 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,24 +1,25 @@ import { useAtomValue } from "@effect/atom-react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import ImageViewing from "react-native-image-viewing"; import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { workspaceFileImageAtom } from "./workspace-file-image-cache"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { PresentationSource } from "../../components/NativePresentation"; function ResolvedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; }) { const [loadError, setLoadError] = useState(null); - const [fullScreenVisible, setFullScreenVisible] = useState(false); + const [preview, setPreview] = useState(null); + const sourceIdentifier = useId(); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], ); - const fullScreenImages = useMemo(() => [imageSource], [imageSource]); return ( @@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} disabled={loadError !== null} className="flex-1 p-4 active:bg-subtle-strong" - onPress={() => setFullScreenVisible(true)} + onPress={() => + setPreview({ + kind: "image", + uri: props.uri, + name: props.accessibilityLabel, + sourceIdentifier, + }) + } > - setLoadError(null)} - onError={(event) => { - setLoadError(event.nativeEvent.error || "The image could not be rendered."); - }} - /> + + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + {loadError !== null ? ( @@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { ) : null} - setFullScreenVisible(false)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreview(null)} /> ); } diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index a798e5ebe4ae..74ccc8cf0bcb 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; @@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp Record> >({}); const [attachments, setAttachments] = useState>([]); - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); const selectedLines = useMemo( () => (target ? getSelectedReviewCommentLines(target) : []), @@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp attachments={attachments} imageBorderRadius={16} imageSize={60} - onPressImage={setPreviewImageUri} + onPressPreview={setPreviewFile} removeButtonPlacement="gutter" onRemove={(imageId) => { setAttachments((current) => @@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp ) : null} - setPreviewImageUri(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreviewFile(null)} /> ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 96445146e158..f7b9f0e2c97c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -35,6 +35,7 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; @@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: { const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); const [previewVideo, setPreviewVideo] = useState(null); - const wasFocusedBeforeVideoRef = useRef(false); + const [previewFile, setPreviewFile] = useState(null); + const wasFocusedBeforePreviewRef = useRef(false); const openVideoPreview = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { - wasFocusedBeforeVideoRef.current = isComposerFocused; + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isComposerFocused], ); - const closeVideoPreview = useCallback(() => { + const openFilePreview = useCallback( + (source: FilePreviewSource) => { + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewVideo(null); + setPreviewFile((current) => current ?? source); + }, + [isComposerFocused], + ); + const closeMediaPreview = useCallback(() => { setPreviewVideo(null); - if (wasFocusedBeforeVideoRef.current) { + setPreviewFile(null); + if (wasFocusedBeforePreviewRef.current) { setTimeout(() => { if (navigation.isFocused()) promptInputRef.current?.focus(); }, 100); @@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: { ? () => undefined : flow.removeAttachment } + onPressPreview={ + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview + } onPressVideo={ isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview } @@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: { - + + ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e1549cef96c2..786b407308b8 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -20,7 +20,7 @@ import { type RefObject, } from "react"; import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import Animated, { FadeIn, FadeInDown, @@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const showStopAction = @@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onExpandedChange?.(isExpanded); }, [isExpanded, onExpandedChange]); - const onPressImage = useCallback( - (uri: string) => { + const onPressPreview = useCallback( + (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; setPreviewVideo(null); - setPreviewImageUri(uri); + setPreviewFile((current) => current ?? source); }, [isFocused], ); const closePreview = useCallback(() => { - setPreviewImageUri(null); + setPreviewFile(null); setPreviewVideo(null); if (wasExpandedBeforePreviewRef.current) { setTimeout(() => { @@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const onPressVideo = useCallback( (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { wasExpandedBeforePreviewRef.current = isFocused; - setPreviewImageUri(null); + setPreviewFile(null); setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); }, [isFocused], @@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer undefined : props.onRemoveDraftImage} - onPressImage={voiceInput.isBusy ? undefined : onPressImage} + onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} /> @@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer size={30} borderRadius={8} compact - onPressImage={onPressImage} + onPressPreview={onPressPreview} onPressVideo={onPressVideo} /> ))} @@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 24af82389036..8234a3c44f43 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -36,6 +36,7 @@ import { useMemo, useRef, useState, + useId, type ReactNode, type RefObject, } from "react"; @@ -62,8 +63,9 @@ import { View, type ViewStyle, } from "react-native"; -import { TouchableOpacity } from "react-native-gesture-handler"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { isPdfFile } from "../../lib/filePreview"; +import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, @@ -212,9 +214,11 @@ export interface ThreadFeedProps { function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; + readonly name: string; readonly className: string; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { + const sourceIdentifier = useId(); const uri = useAssetUrl(props.environmentId, { _tag: "attachment", attachmentId: props.attachmentId, @@ -229,9 +233,17 @@ function MessageAttachmentImage(props: { } return ( - props.onPressImage(uri)}> - - + + + props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) + } + > + + + ); } @@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; + readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; }) { + const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); const { attachment } = props; const videoType = videoMimeType(attachment); + const isPdf = isPdfFile(attachment); + const fileTypeLabel = isPdf + ? "PDF" + : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); + const sizeLabel = formatAttachmentSize(attachment.sizeBytes); const thumbnailUrl = useAssetUrl( props.environmentId, videoType === null @@ -348,26 +367,63 @@ function MessageAttachmentFile(props: { } return ( - shareFile()} + - {opening ? ( - - ) : ( - - )} - - {attachment.name} - - - {formatAttachmentSize(attachment.sizeBytes)} - - + + isPdf + ? props.onPressPreview({ + kind: "pdf", + name: attachment.name, + environmentId: props.environmentId, + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: "application/pdf", + }, + sourceIdentifier, + }) + : shareFile(sourceIdentifier) + } + > + + {opening ? ( + + ) : ( + + )} + + + + {attachment.name} + + + {fileTypeLabel} · {sizeLabel} + + + + + ); } @@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { + const sourceIdentifier = useId(); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: { )} ) : ( - props.onPressImage(props.uri!)} - style={{ alignSelf: "flex-start" }} - > - + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + }) + } + style={{ alignSelf: "flex-start" }} > - setFailedUri(props.uri)} - /> - - + + setFailedUri(props.uri)} + /> + + + )} {props.alt ? ( @@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: { readonly threadId: ThreadId; readonly path: string; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { const assetUrl = useAssetUrlState(props.environmentId, { _tag: "workspace-file", @@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: { sourceKey={props.path} unavailable={assetUrl._tag === "Failure"} alt={props.alt} - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ); } @@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) sourceKey="unavailable" unavailable alt={props.alt} - onPressImage={() => undefined} + onPressPreview={() => undefined} /> ); } @@ -1264,7 +1329,7 @@ function renderFeedEntry( readonly onToggleWorkGroup: (groupId: string) => void; readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly onMarkdownLinkPress: (href: string) => void; readonly renderMarkdownImage: MarkdownImageRenderer; @@ -1375,14 +1440,16 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachmentId={attachment.id} + name={attachment.name} className="aspect-[1.3] w-full rounded-[14px] bg-white/15" - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( ) : ( @@ -1436,14 +1503,16 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachmentId={attachment.id} + name={attachment.name} className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" - onPressImage={props.onPressImage} + onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( ) : ( @@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds: new Set(), }); const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; - const [expandedImage, setExpandedImage] = useState<{ - uri: string; - headers?: Record; - } | null>(null); + const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); useEffect(() => { setExpandedVideo(null); + setExpandedFile(null); }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ @@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); if (relativePath) { void Haptics.selectionAsync(); + if (isPdfFile({ name: relativePath })) { + setExpandedFile( + (current) => + current ?? { + kind: "pdf", + name: relativePath.split("/").at(-1), + environmentId: props.environmentId, + resource: { + _tag: "workspace-file", + threadId: props.threadId, + path: relativePath, + }, + }, + ); + return; + } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), @@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (presentation.href) { + if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { + setExpandedFile( + (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, + ); + return; + } void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, @@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { sourceKey={imageSource.uri} unavailable={false} alt={image.alt} - onPressImage={(uri) => setExpandedImage({ uri })} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); } @@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { threadId={props.threadId} path={imageSource.path} alt={image.alt} - onPressImage={(uri) => setExpandedImage({ uri })} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); }, @@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [suspendEndScrollMaintenanceForDisclosure], ); - const onPressImage = useCallback((uri: string, headers?: Record) => { - setExpandedImage({ uri, headers }); + const onPressPreview = useCallback((source: FilePreviewSource) => { + setExpandedFile((current) => current ?? source); }, []); const onPressVideo = useCallback( (attachment: ChatFileAttachment, sourceIdentifier: string) => { @@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkGroup, onToggleWorkRow, onToggleTurnFold, - onPressImage, + onPressPreview, onPressVideo, onMarkdownLinkPress, renderMarkdownImage, @@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, - onPressImage, + onPressPreview, onPressVideo, onToggleTurnFold, onToggleWorkGroup, @@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedVideo(null)} /> - setExpandedImage(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setExpandedFile(null)} /> ); }); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 561b2a800aeb..b38c0813c6a1 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import type { ImagePickerAsset } from "expo-image-picker"; const mocks = vi.hoisted(() => ({ @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ delete: vi.fn(), open: vi.fn(), size: vi.fn(), + readBase64: vi.fn(), })); vi.mock("expo-file-system", () => { @@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => { mocks.copy(this.uri, destination.uri); } + async base64(): Promise { + return mocks.readBase64(this.uri); + } + delete(): void { mocks.delete(this.uri); } @@ -95,9 +101,115 @@ describe("composer file attachments", () => { mocks.delete.mockReset(); mocks.open.mockReset(); mocks.size.mockReset(); + mocks.readBase64.mockReset(); mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); }); + describe("photo library image conversion", () => { + const jpeg = "/9j/2Q=="; + const photo: ImagePickerAsset = { + uri: "file:///picker/photo.heic", + type: "image", + fileName: "photo.HEIC", + mimeType: "image/heic", + fileSize: 20 * 1024 * 1024, + base64: jpeg, + width: 1, + height: 1, + }; + + it.each(["image/heic", "image/heif", undefined])( + "attaches the native JPEG conversion with matching metadata when the source MIME is %s", + async (mimeType) => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, mimeType }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result).toEqual({ + images: [ + { + id: "attachment-id", + type: "image", + name: "photo.jpg", + mimeType: "image/jpeg", + sizeBytes: 4, + dataUrl: `data:image/jpeg;base64,${jpeg}`, + previewUri: `data:image/jpeg;base64,${jpeg}`, + }, + ], + error: null, + }); + }, + ); + + it.each([ + { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, + { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, + { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, + ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { + const name = `photo.${original.extension}`; + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: name, mimeType: original.mimeType }], + }); + mocks.readBase64.mockResolvedValue(original.base64); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.error).toBeNull(); + expect(result.images).toEqual([ + expect.objectContaining({ + name, + mimeType: original.mimeType, + dataUrl: `data:${original.mimeType};base64,${original.base64}`, + sizeBytes: Buffer.from(original.base64, "base64").byteLength, + }), + ]); + }); + + it("checks the converted JPEG size even when the HEIC source was smaller", async () => { + const oversized = + jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileSize: 42, base64: oversized }], + }); + + await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ + images: [], + error: "'photo.HEIC' exceeds the 10 MB attachment limit.", + }); + }); + + it("does not relabel unconverted HEIC bytes as JPEG", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([]); + expect(result.error).toContain("not a supported image type"); + }); + + it("retains a converted photo when another original cannot be read", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], + }); + mocks.readBase64.mockRejectedValue(new Error("missing file")); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); + expect(result.error).toBe("Failed to read 'missing.gif'."); + }); + }); + describe("photo library videos", () => { const image: ImagePickerAsset = { uri: "file:///picker/photo.png", diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index b4684e5bfc6f..1a66c03c7060 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -351,7 +351,7 @@ export async function pickComposerMedia(input: { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; break; } - const mimeType = asset.mimeType?.toLowerCase(); + let mimeType = asset.mimeType?.toLowerCase(); if (asset.type === "video" || mimeType?.startsWith("video/")) { if (input.maxVideoBytes === undefined) { error = "Video attachments are unavailable here."; @@ -375,35 +375,61 @@ export async function pickComposerMedia(input: { } continue; } - if (!mimeType?.startsWith("image/")) { + if (asset.type !== "image" && !mimeType?.startsWith("image/")) { error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; continue; } - if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { - error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; - continue; - } - const base64 = asset.base64; + let base64 = asset.base64; if (!base64) { error = `Failed to read '${asset.fileName ?? "image"}'.`; continue; } - const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); + let name = asset.fileName?.trim() || "image"; + // The iOS picker returns JPEG base64 even when its metadata describes HEIC, + // PNG, or GIF. Keep supported originals so transparency and animation survive; + // use the native JPEG conversion for formats providers cannot accept. + if (base64.startsWith("/9j/")) { + if ( + mimeType && + mimeType !== "image/jpeg" && + isProviderSendTurnSupportedImageMimeType(mimeType) + ) { + try { + const { File } = await import("expo-file-system"); + base64 = await new File(asset.uri).base64(); + } catch { + error = `Failed to read '${name}'.`; + continue; + } + } else { + mimeType = "image/jpeg"; + if (!/\.jpe?g$/i.test(name)) { + name = `${name.replace(/\.[^.]+$/, "")}.jpg`; + } + } + } + if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) { + error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + continue; + } + + const sizeBytes = estimateBase64ByteSize(base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; continue; } + const dataUrl = `data:${mimeType};base64,${base64}`; attachments.push({ id: uuidv4(), type: "image", - name: asset.fileName ?? "image", + name, mimeType, sizeBytes, - dataUrl: `data:${mimeType};base64,${base64}`, - previewUri: asset.uri, + dataUrl, + previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl, }); } diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts new file mode 100644 index 000000000000..50be5369c7d3 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPdfFile } from "./filePreview"; + +describe("PDF preview detection", () => { + it.each([ + [{ name: "download", mimeType: "application/pdf" }, true], + [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true], + [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true], + [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true], + [{ name: "report.pdf", mimeType: "text/plain" }, false], + [{ name: "report.pdf.exe" }, false], + [{ name: "https://example.com/page?download=report.pdf" }, false], + ])("classifies %j as %s", (file, expected) => { + expect(isPdfFile(file)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/filePreview.ts b/apps/mobile/src/lib/filePreview.ts new file mode 100644 index 000000000000..7ee96476d720 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.ts @@ -0,0 +1,6 @@ +/** MIME metadata wins; use the extension for files reported without a specific type. */ +export function isPdfFile(file: { readonly name: string; readonly mimeType?: string }): boolean { + const mimeType = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase(); + if (mimeType && mimeType !== "application/octet-stream") return mimeType === "application/pdf"; + return /\.pdf$/i.test(file.name.split(/[?#]/, 1)[0] ?? ""); +} diff --git a/apps/mobile/src/lib/localVideoPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts similarity index 72% rename from apps/mobile/src/lib/localVideoPreview.test.ts rename to apps/mobile/src/lib/localAttachmentPreview.test.ts index 706c61521682..2ed83b26f640 100644 --- a/apps/mobile/src/lib/localVideoPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -24,7 +24,7 @@ vi.mock("expo-file-system", () => ({ }, })); -import { loadLocalVideoPreview } from "./localVideoPreview"; +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; const attachment = { type: "file" as const, @@ -45,9 +45,22 @@ beforeEach(() => { mocks.share.mockResolvedValue(undefined); }); -describe("loadLocalVideoPreview", () => { +describe("loadLocalAttachmentPreview", () => { + it("retains and shares a PDF with its original filename and type", async () => { + const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; + const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); + await preview!.share(new AbortController().signal); + expect(mocks.share).toHaveBeenCalledWith( + expect.objectContaining({ + attachment: { name: "report.pdf", mimeType: "application/pdf" }, + }), + ); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); it("resolves the current iOS container and releases its playback lease once", async () => { - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); expect(mocks.retain).toHaveBeenCalledWith(attachment); const release = mocks.retain.mock.results[0]!.value; @@ -62,7 +75,7 @@ describe("loadLocalVideoPreview", () => { async (sourceIdentifier) => { const shared = Promise.withResolvers(); mocks.share.mockReturnValue(shared.promise); - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); const share = preview!.share(new AbortController().signal, sourceIdentifier); expect(mocks.retain).toHaveBeenCalledTimes(2); const releasePlayback = mocks.retain.mock.results[0]!.value; @@ -78,7 +91,7 @@ describe("loadLocalVideoPreview", () => { it("releases a failed share while keeping playback retained", async () => { mocks.share.mockRejectedValue(new Error("Sharing unavailable")); - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); await expect(preview!.share(new AbortController().signal)).rejects.toThrow( "Sharing unavailable", ); @@ -89,7 +102,7 @@ describe("loadLocalVideoPreview", () => { it("releases a load canceled during native module loading", async () => { const controller = new AbortController(); - const loading = loadLocalVideoPreview(attachment, controller.signal); + const loading = loadLocalAttachmentPreview(attachment, controller.signal); controller.abort(); await expect(loading).resolves.toBeNull(); expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); @@ -98,14 +111,14 @@ describe("loadLocalVideoPreview", () => { it("reports missing files and releases their lease", async () => { mocks.exists.mockReturnValue(false); - await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow( - "This video is no longer available. Attach the file again.", - ); + await expect( + loadLocalAttachmentPreview(attachment, new AbortController().signal), + ).rejects.toThrow("This attachment is no longer available. Attach the file again."); expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); }); it("does not start sharing a disposed preview", async () => { - const preview = await loadLocalVideoPreview(attachment, new AbortController().signal); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); preview!.dispose(); await preview!.share(new AbortController().signal); expect(mocks.share).not.toHaveBeenCalled(); diff --git a/apps/mobile/src/lib/localVideoPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts similarity index 88% rename from apps/mobile/src/lib/localVideoPreview.ts rename to apps/mobile/src/lib/localAttachmentPreview.ts index 8398124b96ce..bdd20e2e63d5 100644 --- a/apps/mobile/src/lib/localVideoPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -5,8 +5,8 @@ import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; -/** Retains the draft original for playback and gives each outgoing share its own lease. */ -export async function loadLocalVideoPreview( +/** Retains the draft original for preview and gives each outgoing share its own lease. */ +export async function loadLocalAttachmentPreview( attachment: DraftComposerFileAttachment, signal: AbortSignal, ): Promise { @@ -54,6 +54,6 @@ export async function loadLocalVideoPreview( } catch (cause) { release(); if (signal.aborted) return null; - throw new Error("This video is no longer available. Attach the file again.", { cause }); + throw new Error("This attachment is no longer available. Attach the file again.", { cause }); } } diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index ebc19d82d6da..61b97ab6a86f 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -51,13 +51,12 @@ use a separate implementation and do not need this workaround. ## Native media presentations -`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's -full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child +`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit, +image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child and pass the stable identifier to the presentation. The registry keeps weak references to source views; recycled or compact composer thumbnails can register the same identifier. Identifiers must distinguish simultaneously visible attachments. -This source registration does not own playback and can be reused by a future image -viewer. Android uses a regular view. +The source registration does not own the preview. Android uses a regular view. On iOS, video previews mount `AVPlayerViewController` temporarily inside the registered source and enter full screen through AVKit. AVKit @@ -67,7 +66,25 @@ simulator, that leaves native Close unable to exit full screen. When the source the player uses a standard modal presentation. Programmatic entry uses the same guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; if that selector is unavailable, the player also falls back to a standard modal. -Images retain their existing viewer. + +`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset, +or a retained composer file. On iOS, Quick Look owns image and document layout, controls, +zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail +and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or +another image scroll view over that presentation: Quick Look coordinates its image gestures +with the return to the thumbnail. Missing sources use the standard transition, and Reduce +Motion disables animation. A pending programmatic Close waits until the current presentation +or cancelled dismissal has settled before starting another transition. + +The shared native presenter copies original bytes into its own temporary directory and +removes that copy after dismissal. Network downloads write to disk, and sharing never edits +the source attachment. Draft images use their stored upload data rather than a potentially +expired picker URI. No React Navigation route or custom transition animator is needed. + +The same viewer handles message images, markdown images, PDF attachments and links, +composer thumbnails, and workspace image previews. The workspace PDF web preview has an +Open PDF action for the native viewer. Android retains its image viewer and uses the +system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. Received videos open directly from their signed asset URL. AVKit handles buffering; the client does not download the entire file or show a separate opening overlay before diff --git a/docs/user/composer.md b/docs/user/composer.md index 62db0b073ffd..4e251d5d6d58 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -20,7 +20,13 @@ file uploads, **+** opens a menu beside the button with **Photo Library** and ** Videos use the server's file upload limit. You can also share photos, videos, and files into T3 Code from other apps through the system share sheet. Mobile uploads happen when the message sends, so queued messages keep their files until they deliver. Select a received file on mobile -to save it or open it in another app through the system share sheet. +to preview it or open the system share options. + +Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail +into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return. +Use Share to save a copy or send it to another app. PDFs support page navigation and search. +PDF links in assistant responses open the same preview. On Android, images open in the image +viewer and PDFs open the system chooser. Select a video attachment before or after sending to play it. Web and desktop use the browser's built-in controls. On mobile, videos open in a full-screen player with native playback controls. @@ -36,7 +42,8 @@ On web and desktop, if you reload before a file finishes uploading, the draft ke and shows **Attach again** next to it. Attach the file again or remove it, then send. On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into -the composer or paste them into a message. +the composer or paste them into a message. On iOS, selecting them from **Photo Library** also +converts them to JPEG. The 10 MB image limit applies to the converted photo. On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list From 41adccc83e819c286dcdf32cd8b5f55af8bb0b49 Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:38:19 +0800 Subject: [PATCH 16/42] fix(server): allow long thread IDs in HTTP routes (#8898) --- apps/server/src/server.test.ts | 38 +++++++++++++++++++++++++++++++++- apps/server/src/server.ts | 7 +++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f609f7f1748c..15c16930350e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -103,7 +103,7 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; -import { makeRoutesLayer } from "./server.ts"; +import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; import { isThreadDetailEvent, resolveAvailableEditorsForConfig, @@ -629,6 +629,7 @@ const buildAppUnderTest = (options?: { { disableListenLog: true, disableLogger: true, + routerConfig: HTTP_ROUTER_CONFIG, }, ).pipe( Layer.provide( @@ -1527,6 +1528,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves snapshots for MCP handoff thread IDs above the router default", () => + Effect.gen(function* () { + const threadId = ThreadId.make( + "thread:mcp:abfba0d2-b591-4b7e-aad1-e943d89811fa:handoff%3A0ae5edf4-2ea3-4ee3-ba7c-48de3ac92896%3A2026-08-24T17%3A08%3A52.138Z:0", + ); + const thread = { + ...makeDefaultOrchestrationReadModel().threads[0]!, + id: threadId, + }; + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadDetailSnapshot: (requestedThreadId) => + Effect.succeed( + requestedThreadId === threadId + ? Option.some({ snapshotSequence: 1, thread }) + : Option.none(), + ), + }, + }, + }); + + const response = yield* fetchEffect( + yield* getHttpServerUrl(`/api/orchestration/threads/${encodeURIComponent(threadId)}`), + { headers: { cookie: yield* getAuthenticatedSessionCookieHeader() } }, + ); + const snapshot = yield* responseJsonEffect<{ + readonly thread: { readonly id: ThreadId }; + }>(response); + + assert.equal(response.status, 200); + assert.equal(snapshot.thread.id, threadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("compresses large JSON responses through the composed routes", () => Effect.gen(function* () { const descriptor = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c50244d64973..8c6d4253acaa 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -123,6 +123,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import { forkParked, ServerActivation } from "./serverActivation.ts"; +// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's +// 100-character default for one path segment. +export const HTTP_ROUTER_CONFIG = { + maxParamLength: 512, +} as const; + // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer // already closes the websocket gracefully. Do not add an artificial drain before @@ -675,6 +681,7 @@ export const makeServerLayer = Layer.unwrap( const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { disableLogger: !config.logWebSocketEvents, + routerConfig: HTTP_ROUTER_CONFIG, }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); const serverApplicationLayer = Layer.mergeAll( routesLayer, From 929f7e6479d00754dee4e4554b24b25478b8064d Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:18:57 +0530 Subject: [PATCH 17/42] fix(shared): preserve Windows shell PATH priority (#8748) --- packages/shared/src/shell.test.ts | 10 +++++----- packages/shared/src/shell.ts | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e032af50ca53..e3046c03abed 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -460,7 +460,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }); effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { - it.effect("returns the baseline no-profile PATH patch when node is already available", () => + it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () => Effect.gen(function* () { const readEnvironment = vi.fn( (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => @@ -483,6 +483,8 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { ), ).toEqual({ PATH: [ + "C:\\Shell\\Bin", + "C:\\Windows\\System32", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", @@ -490,8 +492,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", - "C:\\Windows\\System32", ].join(";"), }); expect(readEnvironment).toHaveBeenCalledTimes(1); @@ -532,6 +532,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { PATH: [ "C:\\Profile\\Node", "C:\\Windows\\System32", + "C:\\Shell\\Bin", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", @@ -539,7 +540,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", ].join(";"), FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", @@ -576,11 +576,11 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { ), ).toEqual({ PATH: [ + "C:\\Windows\\System32", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Windows\\System32", ].join(";"), FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 1fdd35cfd0ab..4c86c8886312 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -728,7 +728,9 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm }).PATH; const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); - const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); + // Preserve the order a user's shell uses. These directories fill gaps when + // desktop apps launch without the full interactive-shell PATH. + const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32"); const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); From c50b0b4ef8d61ffe3de1cfa6249f906709f7da1f Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:19:16 +0530 Subject: [PATCH 18/42] fix(web): make WSL settings searchable (#8881) --- .../ConnectionsSettings.logic.test.ts | 20 ++++++++++++++++ .../settings/ConnectionsSettings.logic.ts | 8 +++++++ .../settings/ConnectionsSettings.tsx | 16 +++++++++---- .../settings/SettingsSidebarNav.tsx | 23 ++++++++++++++++++- .../settings/settingsSearch.test.ts | 11 +++++++++ .../src/components/settings/settingsSearch.ts | 13 +++++++++++ 6 files changed, 85 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 290e2daa12b8..74283796a8e2 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, isQrShareableEndpoint, + isWslSettingsRowVisible, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; @@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { preflightError: null, }; +describe("isWslSettingsRowVisible", () => { + it("shows the retry row when the WSL state failed to load", () => { + expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true); + }); + + it("hides an unavailable and unused WSL snapshot", () => { + expect( + isWslSettingsRowVisible({ + state: { ...baseWslState, available: false, wslOnly: false }, + error: null, + }), + ).toBe(false); + }); + + it("shows an available WSL snapshot", () => { + expect(isWslSettingsRowVisible({ state: baseWslState, error: null })).toBe(true); + }); +}); + describe("applyWslEnableSelection", () => { it("clears WSL-only and updates the distro before enabling both backends", async () => { const calls: Array = []; diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index faa0cb6c7543..d683efab3a4a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -11,6 +11,14 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; } +export function isWslSettingsRowVisible(input: { + readonly state: DesktopWslState | null; + readonly error: string | null; +}): boolean { + const { state, error } = input; + return state ? state.available || state.enabled || state.wslOnly : error !== null; +} + export type QrEndpointOption = { /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ readonly id: string; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 18d1b0f1c924..6a65856b9b98 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls import { applyWslEnableSelection, isQrShareableEndpoint, + isWslSettingsRowVisible, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; import { @@ -2766,10 +2767,13 @@ export function ConnectionsSettings() { // retry so the row doesn't flicker away, and the button reflects the // loading state. With no error we simply haven't loaded yet (or WSL // management isn't available), so render nothing. - if (desktopWslError && canManageLocalBackend) { + if ( + isWslSettingsRowVisible({ state: null, error: desktopWslError }) && + canManageLocalBackend + ) { return ( {desktopWslError}} control={ @@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { // be stranded on a WSL preference they can't clear, so render a recovery // row that switches back to Windows. When WSL is unavailable AND unused, // there's nothing to recover — keep the section hidden as before. + if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) { + return null; + } if (!desktopWslState.available) { - if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null; return ( (null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const results = useMemo(() => searchSettings(query), [query]); + const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); + const searchableItems = useMemo(() => { + const wslState = desktopWsl.data; + const rowRenders = isWslSettingsRowVisible({ + state: wslState, + error: desktopWsl.error, + }); + if (rowRenders) { + return SETTINGS_SEARCH_ITEMS; + } + return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend"); + }, [desktopWsl.data, desktopWsl.error]); + const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; + useEffect(() => { + setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0))); + }, [results.length]); + useEffect(() => { const result = results[activeResultIndex]; if (!result) return; diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index c560d0b43d94..5bf4b4f704d0 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -71,6 +71,17 @@ describe("searchSettings", () => { it("hides desktop-only settings from browser search", () => { expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); expect(searchSettings("quit confirmation")).toEqual([]); + expect(searchSettings("wsl")).toEqual([]); + }); + + it("registers the WSL backend as a desktop-only setting", () => { + expect(SETTINGS_SEARCH_ITEMS).toContainEqual({ + id: "wsl-backend", + title: "WSL backend", + to: "/settings/connections", + desktopOnly: true, + windowsOnly: true, + }); }); it("keeps catalog result ids unique", () => { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1e0d715f68b2..d5384d1e5087 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,4 +1,5 @@ import { isElectron } from "~/env"; +import { isWindowsPlatform } from "~/lib/utils"; export type SettingsPath = | "/settings/general" @@ -18,6 +19,9 @@ export interface SettingsSearchItem { // Its row only renders in the desktop app, so a browser result would land on // an anchor that isn't there. readonly desktopOnly?: boolean; + // Its row only renders on Windows desktop, so other desktop platforms must + // not expose a result that points to a missing anchor. + readonly windowsOnly?: boolean; } /** @@ -259,6 +263,13 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Remote environments", to: "/settings/connections", }, + { + id: "wsl-backend", + title: "WSL backend", + to: "/settings/connections", + desktopOnly: true, + windowsOnly: true, + }, { id: "archive", title: "Archived threads", @@ -304,6 +315,8 @@ export function searchSettings( return items.filter( (item) => (isElectron || item.desktopOnly !== true) && + (!item.windowsOnly || + isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) && normalizeSearchText(item.title).includes(normalizedQuery), ); } From 17f00f60248374aafa2efb9b54ff08ce52e60a0a Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:20:02 +0530 Subject: [PATCH 19/42] feat(web): add expand/collapse all control to the files surface (#8889) --- .../src/components/files/FileBrowserPanel.tsx | 41 ++++++++++++- .../files/fileTreeExpansion.test.ts | 60 +++++++++++++++++++ .../src/components/files/fileTreeExpansion.ts | 55 +++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/files/fileTreeExpansion.test.ts create mode 100644 apps/web/src/components/files/fileTreeExpansion.ts diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 442bcb634d15..767492ba137a 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -3,9 +3,9 @@ import type { ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; -import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; +import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { RotateCw } from "lucide-react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; @@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -121,6 +122,10 @@ export default function FileBrowserPanel({ ); const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); + const directoryPaths = useMemo( + () => entries.filter((entry) => entry.kind === "directory").map(treePath), + [entries], + ); const previousTreePathsRef = useRef([]); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); @@ -252,6 +257,12 @@ export default function FileBrowserPanel({ unsafeCSS: TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); + const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => + areAllDirectoriesExpanded(currentModel, directoryPaths), + ); + const toggleAllDirectories = () => { + setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded); + }; const handleSearchValueChange = (value: string) => { if (value.trim().length === 0) { search.close(); @@ -376,6 +387,32 @@ export default function FileBrowserPanel({ onValueChange={handleSearchValueChange} onClose={search.close} /> + {directoryPaths.length > 0 ? ( + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null}
{entriesQuery.error && entriesQuery.data === null ? (
{entriesQuery.error}
diff --git a/apps/web/src/components/files/fileTreeExpansion.test.ts b/apps/web/src/components/files/fileTreeExpansion.test.ts new file mode 100644 index 000000000000..1fba6957728f --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; + +type FakeDirectoryItem = { + isDirectory: () => true; + isExpanded: () => boolean; + expand: () => void; + collapse: () => void; +}; + +function makeModel(expanded: Record) { + const items = new Map(); + return { + getItem: (path: string) => { + const existing = items.get(path); + if (existing !== undefined) return existing; + const item: FakeDirectoryItem = { + isDirectory: () => true, + isExpanded: () => expanded[path] ?? false, + expand: () => { + expanded[path] = true; + }, + collapse: () => { + expanded[path] = false; + }, + }; + items.set(path, item); + return item; + }, + }; +} + +describe("file tree expansion", () => { + it("requires at least one directory and detects whether all are expanded", () => { + const model = makeModel({ "src/": true, "test/": true }); + expect(areAllDirectoriesExpanded(model, [])).toBe(false); + expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true); + expect( + areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]), + ).toBe(false); + }); + + it("expands and collapses every directory", () => { + const expanded = { "src/": true, "test/": false }; + const model = makeModel(expanded); + setAllDirectoriesExpanded(model, ["src/", "test/"], true); + expect(expanded).toEqual({ "src/": true, "test/": true }); + setAllDirectoriesExpanded(model, ["src/", "test/"], false); + expect(expanded).toEqual({ "src/": false, "test/": false }); + }); + + it("skips directories already at the requested state", () => { + const model = makeModel({ "src/": true }); + const item = model.getItem("src/"); + const collapse = vi.spyOn(item, "collapse"); + setAllDirectoriesExpanded(model, ["src/"], true); + expect(collapse).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/files/fileTreeExpansion.ts b/apps/web/src/components/files/fileTreeExpansion.ts new file mode 100644 index 000000000000..221e62b64c96 --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.ts @@ -0,0 +1,55 @@ +export interface FileTreeExpansionModel { + getItem(path: string): unknown; +} + +type DirectoryHandle = { + isDirectory(): boolean; + isExpanded(): boolean; + expand(): void; + collapse(): void; +}; + +function asDirectoryHandle(item: unknown): DirectoryHandle | null { + if ( + typeof item !== "object" || + item === null || + !("isDirectory" in item) || + typeof item.isDirectory !== "function" || + !item.isDirectory() || + !("isExpanded" in item) || + typeof item.isExpanded !== "function" || + !("expand" in item) || + typeof item.expand !== "function" || + !("collapse" in item) || + typeof item.collapse !== "function" + ) { + return null; + } + return item as DirectoryHandle; +} + +export function areAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], +): boolean { + return ( + directoryPaths.length > 0 && + directoryPaths.every((path) => { + const item = asDirectoryHandle(model.getItem(path)); + return item !== null && item.isExpanded(); + }) + ); +} + +export function setAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], + expanded: boolean, +): void { + for (const path of directoryPaths) { + const item = asDirectoryHandle(model.getItem(path)); + if (item === null || item.isExpanded() === expanded) continue; + if (expanded) item.expand(); + else item.collapse(); + } +} From 0df043fd4eaa190eb491a3060836156eb0ae915e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 16:58:56 -0700 Subject: [PATCH 20/42] Add auto_review configuration to coderabbit.yaml --- .coderabbit.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 24c09911939d..8ecaf32dc7e1 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,2 +1,4 @@ reviews: - review_status: false + review_status: false + auto_review: + enabled: false From 85b656ff300f71060ad6305c7e1e29a72b442ce9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 17:42:50 -0700 Subject: [PATCH 21/42] style: format CodeRabbit configuration --- .coderabbit.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 8ecaf32dc7e1..6fc9f6f1a1f7 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,4 +1,4 @@ reviews: - review_status: false - auto_review: - enabled: false + review_status: false + auto_review: + enabled: false From 9bc7a56848eb7c7546605e54ff02c6abea176f96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 19:31:47 -0700 Subject: [PATCH 22/42] feat(mobile): upload attachments while composing (#8978) Co-authored-by: Julius Marminge --- apps/mobile/src/Stack.tsx | 2 + .../components/ComposerAttachmentStrip.tsx | 52 ++- .../features/cloud/CloudAuthProvider.test.ts | 6 + .../src/features/cloud/CloudAuthProvider.tsx | 82 +++-- .../mobile/src/features/cloud/cloud-drafts.ts | 46 +++ .../features/threads/NewTaskDraftScreen.tsx | 23 +- .../src/features/threads/ThreadComposer.tsx | 23 +- .../features/threads/use-project-actions.ts | 3 + apps/mobile/src/lib/attachmentUpload.test.ts | 139 +++++++- apps/mobile/src/lib/attachmentUpload.ts | 143 +++++--- apps/mobile/src/lib/composer-image-schema.ts | 2 + .../lib/composerAttachmentUploadQueue.test.ts | 258 ++++++++++++++ .../src/lib/composerAttachmentUploadQueue.ts | 193 +++++++++++ apps/mobile/src/lib/composerImages.ts | 2 + apps/mobile/src/lib/projectThreadStartTurn.ts | 5 +- .../src/state/composer-attachment-uploads.ts | 126 +++++++ .../src/state/use-composer-drafts.test.ts | 150 ++++++++ apps/mobile/src/state/use-composer-drafts.ts | 324 +++++++++++++++++- .../src/state/use-thread-composer-state.ts | 17 +- .../src/state/use-thread-outbox-drain.test.ts | 67 ++++ .../src/state/use-thread-outbox-drain.ts | 53 ++- docs/internals/connection-runtime.md | 13 + docs/user/composer.md | 11 +- 23 files changed, 1622 insertions(+), 118 deletions(-) create mode 100644 apps/mobile/src/features/cloud/cloud-drafts.ts create mode 100644 apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts create mode 100644 apps/mobile/src/lib/composerAttachmentUploadQueue.ts create mode 100644 apps/mobile/src/state/composer-attachment-uploads.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..57303a1bb001 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; +import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string { // each enqueue, shell change, or reconnect. function ThreadOutboxDrainWorker() { useThreadOutboxDrain(); + useComposerAttachmentUploadWorker(); return null; } diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 96640f195836..16f0d422af78 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -10,8 +10,14 @@ import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { PresentationSource } from "./NativePresentation"; import type { FilePreviewSource } from "./FilePreviewModal"; import { isPdfFile } from "../lib/filePreview"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + retryComposerAttachmentUpload, + useComposerAttachmentUploadState, +} from "../state/composer-attachment-uploads"; export interface ComposerAttachmentStripProps { + readonly environmentId?: EnvironmentId; /** Attachments to display. */ readonly attachments: ReadonlyArray; /** Called when the user removes an attachment. */ @@ -30,7 +36,8 @@ export interface ComposerAttachmentStripProps { readonly removeButtonPlacement?: "overlay" | "gutter"; } -export function ComposerAttachmentThumbnail(props: { +type ComposerAttachmentThumbnailProps = { + readonly environmentId?: EnvironmentId; readonly attachment: DraftComposerAttachment; readonly size: number; readonly borderRadius: number; @@ -40,7 +47,47 @@ export function ComposerAttachmentThumbnail(props: { attachment: DraftComposerFileAttachment, sourceIdentifier: string, ) => void; -}) { +}; + +export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) { + const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id); + return ( + + + {upload && upload.status !== "ready" ? ( + + props.environmentId && + retryComposerAttachmentUpload(props.environmentId, props.attachment.id) + } + className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5" + > + + {!props.compact ? ( + + {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`} + + ) : null} + + ) : null} + + ); +} + +function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { const { attachment } = props; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; if (attachment.type === "image") { @@ -213,6 +260,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { }} > ({ }, })); +vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); +vi.mock("../../state/use-composer-drafts", () => ({ + getComposerCloudAccountId: vi.fn(async () => null), + restoreCloudComposerDrafts: vi.fn(async () => undefined), +})); + vi.mock("./publicConfig", () => ({ resolveCloudPublicConfig: vi.fn(() => ({ clerk: { publishableKey: null }, diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index f7ece97cbaa9..fffdd2343044 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -5,14 +5,18 @@ import { reportAtomCommandResult, settleAsyncResult, settlePromise, + squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; -import { environmentCatalog } from "../../connection/catalog"; import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomCommand } from "../../state/use-atom-command"; +import { + getComposerCloudAccountId, + restoreCloudComposerDrafts, +} from "../../state/use-composer-drafts"; import { releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, @@ -20,6 +24,7 @@ import { } from "../agent-awareness/remoteRegistration"; import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +import { removeCloudEnvironments } from "./cloud-drafts"; function resetManagedRelayTokenCache() { return settleAsyncResult(() => @@ -47,7 +52,7 @@ export function activateCloudRelayAccount( function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); - const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { + const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, { reportFailure: false, reportDefect: false, }); @@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { clearConnectOnboardingRequest(); } - const queueAccountCleanup = ( + const cleanUpAccount = async ( previous: { readonly userId: string; readonly provider: () => Promise; } | null, + accountId: string | null, ) => { - const previousTransition = accountTransitionRef.current ?? Promise.resolve(); - accountTransitionRef.current = previousTransition.then(async () => { - const cleanup = [ - resetManagedRelayTokenCache(), - removeRelayEnvironments(), - ...(previous - ? [ - settleAsyncResult(() => - runtime.runPromiseExit( - unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), - ), + const removal = await removeRelayEnvironments(accountId); + if (removal._tag !== "Success") throw squashAtomCommandFailure(removal); + const cleanup = [ + resetManagedRelayTokenCache(), + ...(previous + ? [ + settleAsyncResult(() => + runtime.runPromiseExit( + unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), ), - ] - : []), - ]; - const results = await Promise.all(cleanup); - for (const result of results) { - reportAtomCommandResult(result, { label: "cloud account cleanup" }); - } - }); + ), + ] + : []), + ]; + const results = await Promise.all(cleanup); + for (const result of results) { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + } + }; + const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => { + const previousTransition = accountTransitionRef.current ?? Promise.resolve(); + accountTransitionRef.current = previousTransition + .catch(() => {}) + .then(() => cleanUpAccount(previous, previousObservedAccount ?? null)); return accountTransitionRef.current; }; @@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { previousTokenProviderRef.current = null; deactivateCloudRelayAccount(); if (previousObservedAccount !== null) { - void queueAccountCleanup(previous); + void settlePromise(() => queueAccountCleanup(previous)).then((result) => { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + }); } return; } @@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { } }; const activateAfterTransition = (transition: Promise) => { - void (async () => { - const result = await settlePromise(async () => { - await transition; - activateSession(); - }); - reportAtomCommandResult(result, { label: "cloud account activation" }); + const activation = (async () => { + await transition; + if (cancelled) return; + const storedAccount = await getComposerCloudAccountId(); + if (storedAccount !== null && storedAccount !== userId) { + await cleanUpAccount(null, storedAccount); + } + if (cancelled) return; + await restoreCloudComposerDrafts(userId); + activateSession(); })(); + accountTransitionRef.current = activation; + void settlePromise(() => activation).then((result) => { + reportAtomCommandResult(result, { label: "cloud account activation" }); + }); }; if ( previousObservedAccount !== undefined && @@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { deactivateCloudRelayAccount(); activateAfterTransition(queueAccountCleanup(previous)); } else { - activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); + // A failed disk write can be retried. The persisted account check above + // still requires cleanup before activating a different account. + activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {})); } return () => { diff --git a/apps/mobile/src/features/cloud/cloud-drafts.ts b/apps/mobile/src/features/cloud/cloud-drafts.ts new file mode 100644 index 000000000000..bc41b2b41fe0 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloud-drafts.ts @@ -0,0 +1,46 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { connectionAtomRuntime } from "../../connection/runtime"; +import { archiveCloudComposerDrafts } from "../../state/use-composer-drafts"; + +export class CloudDraftArchiveError extends Schema.TaggedErrorClass()( + "CloudDraftArchiveError", + { + environmentCount: Schema.Number, + hasAccountId: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not preserve local drafts for ${this.environmentCount} cloud environments before sign-out.`; + } +} + +export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntime, { + label: "cloud:preserve-drafts-and-remove-environments", + execute: Effect.fn("removeCloudEnvironments")(function* (accountId: string | null) { + const registry = yield* EnvironmentRegistry; + const entries = yield* SubscriptionRef.get(registry.entries); + const environmentIds = new Set( + [...entries.values()] + .filter((entry) => entry.target._tag === "RelayConnectionTarget") + .map((entry) => entry.target.environmentId), + ); + // Credentials are already revoked. A failed backup must leave the local + // owners intact so a later sign-in can retry without losing their files. + yield* Effect.tryPromise({ + try: () => archiveCloudComposerDrafts(accountId, environmentIds), + catch: (cause) => + new CloudDraftArchiveError({ + environmentCount: environmentIds.size, + hasAccountId: accountId !== null, + cause, + }), + }); + yield* registry.removeRelayEnvironments(); + }), +}); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index f7b9f0e2c97c..8f8cf485f6f8 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { CommonActions, @@ -35,6 +36,10 @@ import { import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadsAtom, +} from "../../state/composer-attachment-uploads"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { ProviderIcon } from "../../components/ProviderIcon"; @@ -164,6 +169,16 @@ export function NewTaskDraftScreen(props: { connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; + const uploadStates = useAtomValue(composerAttachmentUploadsAtom); + const attachmentBlockReason = selectedProject + ? composerAttachmentUploadBlockReason({ + environmentId: selectedProject.environmentId, + attachments: flow.attachments, + connected: environmentConnected, + serverConfig: selectedEnvironmentServerConfig, + states: uploadStates, + }) + : null; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); @@ -863,6 +878,7 @@ export function NewTaskDraftScreen(props: { const initialMessageText = draft.text.trim(); if ( + attachmentBlockReason !== null || !modelSelection || initialMessageText.length === 0 || flow.submitting || @@ -1017,6 +1033,7 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const canStart = + attachmentBlockReason === null && Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && flow.prompt.trim().length > 0 && @@ -1216,6 +1233,7 @@ export function NewTaskDraftScreen(props: { {flow.attachments.length > 0 ? ( { @@ -617,6 +630,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer exiting={FadeOut.duration(120)} > undefined : props.onRemoveDraftImage} onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} @@ -668,6 +682,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {props.draftAttachments.slice(0, 3).map((attachment) => ( ) : ( )} @@ -794,7 +809,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : voicePresentation.showsSend ? ( { await input.onAttachmentsUploaded(draftAttachments); return "persisted"; diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 488c8375a3e4..5e8a34dd1cdb 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ runAtomCommand: vi.fn(), readAtom: vi.fn(), upload: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ @@ -53,13 +55,25 @@ vi.mock("./uuid", () => ({ vi.mock("expo-file-system", () => ({ File: class { - constructor(readonly uri: string) {} + readonly uri: string; + exists = true; + constructor(uri: string, name?: string) { + this.uri = name ? `${uri}/${name}` : uri; + } + create() {} + write(bytes: string, options: unknown) { + mocks.writeFile(this.uri, bytes, options); + } + delete() { + mocks.deleteFile(this.uri); + } upload(url: string, options: unknown) { return mocks.upload(this.uri, url, options); } }, Paths: { + cache: "file:///cache", get document() { return { uri: mocks.documentUri }; }, @@ -158,6 +172,8 @@ describe("prepareTurnAttachments", () => { mocks.runAtomCommand.mockReset(); mocks.readAtom.mockReset(); mocks.upload.mockReset(); + mocks.writeFile.mockReset(); + mocks.deleteFile.mockReset(); mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => command === mocks.createUploadUrl @@ -198,11 +214,11 @@ describe("prepareTurnAttachments", () => { expect(mocks.upload).toHaveBeenCalledWith( "file:///documents/report.pdf", "https://environment.example/api/attachments/upload/signed", - { + expect.objectContaining({ httpMethod: "POST", uploadType: 0, headers: { "Content-Type": "application/pdf" }, - }, + }), ); expect(prepared.status).toBe("ready"); if (prepared.status !== "ready") return; @@ -354,6 +370,123 @@ describe("prepareTurnAttachments", () => { expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); }); + it("uploads image bytes over HTTP while retaining the durable offline image", async () => { + const persisted = vi.fn(async () => "persisted" as const); + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [image], + supportsImageUploads: true, + persistUploadedReferences: persisted, + }); + expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { + encoding: "base64", + }); + expect(mocks.upload).toHaveBeenCalledWith( + "file:///cache/t3-upload-uuid", + "https://environment.example/api/attachments/upload/signed", + expect.objectContaining({ headers: { "Content-Type": "image/png" } }), + ); + expect(mocks.deleteFile).toHaveBeenCalledExactlyOnceWith("file:///cache/t3-upload-uuid"); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "image", + id: MINTED_ID, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }, + ]); + expect(prepared.draftAttachments).toEqual([ + { ...image, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId }, + ]); + expect(persisted).toHaveBeenCalledWith(prepared.draftAttachments); + }); + + it("reuses an uploaded image and reuploads its local bytes after server expiry", async () => { + const saved = { + ...image, + uploadedAttachmentId: "saved-image", + uploadEnvironmentId: environmentId, + }; + const reused = await prepareTurnAttachments({ + environmentId, + attachments: [saved], + supportsImageUploads: true, + }); + expect(reused.status === "ready" && reused.attachments[0]).toEqual({ + type: "image", + id: "saved-image", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }); + expect(mocks.upload).not.toHaveBeenCalled(); + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + const restored = await prepareTurnAttachments({ + environmentId, + attachments: [saved], + supportsImageUploads: true, + }); + expect(restored.status === "ready" && restored.draftAttachments[0]).toEqual({ + ...saved, + uploadedAttachmentId: MINTED_ID, + }); + expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { + encoding: "base64", + }); + }); + + it("does not reuse an image upload from another environment", async () => { + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [ + { + ...image, + uploadedAttachmentId: "other-image", + uploadEnvironmentId: EnvironmentId.make("other"), + }, + ], + supportsImageUploads: true, + }); + expect(mocks.executeAtomQuery).not.toHaveBeenCalled(); + expect(mocks.upload).toHaveBeenCalledOnce(); + expect(prepared.status === "ready" && prepared.draftAttachments[0]?.uploadEnvironmentId).toBe( + environmentId, + ); + }); + + it("aborts an active transfer without dropping local bytes or stamping a partial upload", async () => { + const started = Promise.withResolvers(); + const controller = new AbortController(); + const persist = vi.fn(async () => "persisted" as const); + mocks.upload.mockImplementation( + (_uri: string, _url: string, options: { signal: AbortSignal }) => + new Promise((_, reject) => { + options.signal.addEventListener("abort", () => reject(new Error("cancelled")), { + once: true, + }); + started.resolve(); + }), + ); + const preparing = prepareTurnAttachments({ + environmentId, + attachments: [file], + signal: controller.signal, + persistUploadedReferences: persist, + }); + await started.promise; + controller.abort(); + expect(await preparing).toEqual({ status: "abandoned" }); + expect(persist).not.toHaveBeenCalled(); + expect(mocks.deleteFile).not.toHaveBeenCalled(); + expect(removeCallsFor(MINTED_ID)).toBe(1); + }); + it("removes pending uploads when the native HTTP request fails", async () => { mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index afe669d6fe64..f39329373dd7 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -9,9 +9,11 @@ import { import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { ChatFileAttachment, + ChatImageAttachment, EnvironmentId, UploadChatImageAttachment, } from "@t3tools/contracts"; +import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../state/atom-registry"; @@ -20,6 +22,7 @@ import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; +import { uuidv4 } from "./uuid"; /** * This module owns the server side of a composer attachment's lifecycle. @@ -31,7 +34,10 @@ import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./co * owned by `removeThreadOutboxMessage` / the composer draft mutators, which * release files through `releaseUnusedComposerAttachmentFiles`. */ -export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment; +export type UploadedMobileAttachment = + | UploadChatImageAttachment + | ChatImageAttachment + | ChatFileAttachment; export function validateDraftFileAttachments(input: { readonly attachments: ReadonlyArray; @@ -56,7 +62,7 @@ export function validateDraftFileAttachments(input: { return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null; } -/** Keep uploaded file ids on durable drafts so a later send can reuse their bytes. */ +/** Keep uploaded ids alongside the local bytes so a later send can reuse them. */ export function withUploadedMobileAttachmentReferences(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; @@ -65,8 +71,9 @@ export function withUploadedMobileAttachmentReferences(input: { return input.attachments.map((attachment, index) => { const uploaded = input.uploadedAttachments[index]; if ( - attachment.type !== "file" || - uploaded?.type !== "file" || + !uploaded || + !("id" in uploaded) || + attachment.type !== uploaded.type || (attachment.uploadedAttachmentId === uploaded.id && attachment.uploadEnvironmentId === input.environmentId) ) { @@ -145,21 +152,73 @@ export type PrepareTurnAttachmentsResult = | PreparedTurnAttachments | { readonly status: "abandoned" }; +function uploadedReference( + attachment: DraftComposerAttachment, + id: string, +): ChatImageAttachment | ChatFileAttachment { + const fields = { + id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + return attachment.type === "image" ? { type: "image", ...fields } : { type: "file", ...fields }; +} + +function attachmentUploadInput(attachment: DraftComposerAttachment) { + const fields = { + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + if (attachment.type === "file") return { type: "file" as const, ...fields }; + const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( + (type) => type === attachment.mimeType.toLowerCase(), + ); + if (!mimeType) throw new Error(`Unsupported image type for '${attachment.name}'.`); + return { ...fields, mimeType }; +} + async function uploadFileBytes( - attachment: Extract, + attachment: DraftComposerAttachment, url: string, + signal: AbortSignal, + onProgress?: (progress: number) => void, ): Promise { const { File, Paths, UploadType } = await import("expo-file-system"); - const fileUri = - resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? - attachment.fileUri; - const result = await new File(fileUri).upload(url, { - httpMethod: "POST", - uploadType: UploadType.BINARY_CONTENT, - headers: { "Content-Type": attachment.mimeType }, - }); - if (result.status < 200 || result.status >= 300) { - throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); + if (signal.aborted) throw new Error("Upload cancelled."); + const file = + attachment.type === "image" + ? new File(Paths.cache, `t3-upload-${uuidv4()}`) + : new File( + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri, + ); + try { + if (attachment.type === "image") { + file.create(); + file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), { + encoding: "base64", + }); + } + const result = await file.upload(url, { + httpMethod: "POST", + uploadType: UploadType.BINARY_CONTENT, + headers: { "Content-Type": attachment.mimeType }, + signal, + ...(onProgress + ? { + onProgress: ({ bytesSent, totalBytes }) => { + if (totalBytes > 0) onProgress(bytesSent / totalBytes); + }, + } + : {}), + }); + if (result.status < 200 || result.status >= 300) { + throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); + } + } finally { + if (attachment.type === "image" && file.exists) file.delete(); } } @@ -176,11 +235,16 @@ async function uploadFileBytes( export async function prepareTurnAttachments(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; + /** Older environments continue to receive inline images. */ + readonly supportsImageUploads?: boolean; + readonly signal?: AbortSignal; + readonly onUploadProgress?: (attachmentId: string, progress: number) => void; readonly persistUploadedReferences?: ( draftAttachments: ReadonlyArray, ) => Promise<"persisted" | "abandon">; }): Promise { const { environmentId } = input; + if (input.signal?.aborted) return { status: "abandoned" }; const files = input.attachments.filter((attachment) => attachment.type === "file"); const ready = ( attachments: ReadonlyArray, @@ -194,7 +258,7 @@ export async function prepareTurnAttachments(input: { releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), }); - if (files.length === 0) { + if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { return ready( toUploadChatImageAttachments( input.attachments.filter((attachment) => attachment.type === "image"), @@ -214,9 +278,13 @@ export async function prepareTurnAttachments(input: { const uploadedAttachments: UploadedMobileAttachment[] = []; const pendingAttachmentIds: string[] = []; const createdAttachmentIds: string[] = []; + const controller = new AbortController(); + const abort = () => controller.abort(); + input.signal?.addEventListener("abort", abort, { once: true }); try { for (const attachment of input.attachments) { - if (attachment.type === "image") { + if (controller.signal.aborted) throw new Error("Upload cancelled."); + if (attachment.type === "image" && !input.supportsImageUploads) { uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); continue; } @@ -238,13 +306,7 @@ export async function prepareTurnAttachments(input: { } if (verification.status === "verified") { pendingAttachmentIds.push(attachment.uploadedAttachmentId); - uploadedAttachments.push({ - type: "file", - id: attachment.uploadedAttachmentId, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }); + uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId)); continue; } // "missing": the pending upload expired, upload the bytes again. @@ -255,12 +317,7 @@ export async function prepareTurnAttachments(input: { createUploadUrl: attachmentEnvironment.createUploadUrl, remove: attachmentEnvironment.remove, environmentId, - upload: { - type: "file", - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }, + upload: attachmentUploadInput(attachment), // Read the connection at transfer time: the environment may have // reconnected on a new base URL since this cycle started. resolveUploadUrl: (relativeUrl) => { @@ -272,11 +329,18 @@ export async function prepareTurnAttachments(input: { : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl); }, transport: (url) => ({ - done: uploadFileBytes(attachment, url), - // expo-file-system uploads cannot abort mid-flight. - abort: () => {}, + done: uploadFileBytes( + attachment, + url, + controller.signal, + input.onUploadProgress + ? (progress) => input.onUploadProgress?.(attachment.id, progress) + : undefined, + ), + abort, }), onMinted: (attachmentId) => { + if (controller.signal.aborted) return "cancel"; pendingAttachmentIds.push(attachmentId); createdAttachmentIds.push(attachmentId); return "continue"; @@ -287,15 +351,11 @@ export async function prepareTurnAttachments(input: { ? result.error : new Error(`Upload failed for '${attachment.name}'.`); } - uploadedAttachments.push({ - type: "file", - id: result.attachmentId, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }); + uploadedAttachments.push(uploadedReference(attachment, result.attachmentId)); } + if (controller.signal.aborted) throw new Error("Upload cancelled."); + const draftAttachments = withUploadedMobileAttachmentReferences({ environmentId, attachments: input.attachments, @@ -313,6 +373,9 @@ export async function prepareTurnAttachments(input: { return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); } catch (error) { await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); + if (controller.signal.aborted) return { status: "abandoned" }; throw error; + } finally { + input.signal?.removeEventListener("abort", abort); } } diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 401a5fd512c3..3303dad36b0c 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -9,6 +9,8 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ mimeType: Schema.String, sizeBytes: Schema.Number, dataUrl: Schema.String, + uploadedAttachmentId: Schema.optional(Schema.String), + uploadEnvironmentId: Schema.optional(EnvironmentId), }); export const DraftComposerFileAttachmentSchema = Schema.Struct({ diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts new file mode 100644 index 000000000000..6b040b698e3d --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -0,0 +1,258 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadKey, + composerDraftEnvironmentId, + createComposerAttachmentUploadQueue, + type ComposerAttachmentUploadRequest, + type ComposerAttachmentUploadState, +} from "./composerAttachmentUploadQueue"; + +const environmentId = EnvironmentId.make("environment-1"); +function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest { + return { + environmentId: environment, + attachment: { + id, + type: "file", + name: `${id}.pdf`, + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: `file:///documents/${id}.pdf`, + }, + }; +} + +describe("composer attachment upload queue", () => { + it("bounds concurrency, deduplicates updates, and drains all attachments", async () => { + const gates = new Map>>(); + const fourthStarted = Promise.withResolvers(); + const firstThreeStarted = Promise.withResolvers(); + let active = 0; + let maximum = 0; + const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => { + active += 1; + maximum = Math.max(maximum, active); + const gate = Promise.withResolvers(); + gates.set(input.attachment.id, gate); + if (gates.size === 3) firstThreeStarted.resolve(); + if (gates.size === 4) fourthStarted.resolve(); + try { + return await gate.promise; + } finally { + active -= 1; + } + }); + const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); + const requests = [request("one"), request("two"), request("three"), request("four")]; + queue.sync(requests); + queue.sync(requests); + await firstThreeStarted.promise; + expect(upload).toHaveBeenCalledTimes(3); + gates.get("one")!.resolve(true); + await fourthStarted.promise; + for (const gate of gates.values()) gate.resolve(true); + await queue.settled(); + queue.sync(requests); + await queue.settled(); + expect(maximum).toBe(3); + expect(upload).toHaveBeenCalledTimes(4); + queue.dispose(); + }); + + it("cancels on disconnect and resumes from the same local draft on reconnect", async () => { + const started = Promise.withResolvers(); + let states: Readonly> = {}; + let signal: AbortSignal | undefined; + const upload = vi.fn( + async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => { + signal = currentSignal; + started.resolve(); + return new Promise((resolve) => + currentSignal.addEventListener("abort", () => resolve(false), { once: true }), + ); + }, + ); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("offline-draft"); + queue.sync([local]); + await started.promise; + queue.sync([]); + await queue.settled(); + expect(signal?.aborted).toBe(true); + expect(states).toEqual({}); + upload.mockResolvedValueOnce(true); + queue.sync([local]); + await queue.settled(); + expect(upload.mock.calls[1]?.[0]).toBe(local); + expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ + status: "ready", + }); + expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" }); + queue.dispose(); + }); + + it("ignores a late completion after removal or environment switch", async () => { + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + let states: Readonly> = {}; + const upload = vi.fn(async () => { + started.resolve(); + return gate.promise; + }); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + queue.sync([request("photo")]); + await started.promise; + upload.mockResolvedValueOnce(true); + const other = EnvironmentId.make("environment-2"); + queue.sync([request("photo", other)]); + gate.resolve(true); + await queue.settled(); + expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } }); + queue.sync([]); + expect(states).toEqual({}); + queue.dispose(); + }); + + it("restarts a re-added attachment after its aborted transfer finishes settling", async () => { + const firstStarted = Promise.withResolvers(); + const firstSettled = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const secondSettled = Promise.withResolvers(); + let states: Readonly> = {}; + let firstSignal: AbortSignal | undefined; + const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => { + if (!firstSignal) { + firstSignal = signal; + firstStarted.resolve(); + return firstSettled.promise; + } + secondStarted.resolve(); + return secondSettled.promise; + }); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("re-added"); + queue.sync([local]); + await firstStarted.promise; + queue.sync([]); + queue.sync([local]); + expect(firstSignal?.aborted).toBe(true); + expect(upload).toHaveBeenCalledOnce(); + firstSettled.resolve(false); + await secondStarted.promise; + expect(upload).toHaveBeenCalledTimes(2); + secondSettled.resolve(true); + await queue.settled(); + expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ + status: "ready", + }); + queue.dispose(); + }); + + it("keeps failures stable until retry and reports bounded progress", async () => { + let states: Readonly> = {}; + const progress: number[] = []; + const upload = vi.fn( + async ( + _request: ComposerAttachmentUploadRequest, + _signal: AbortSignal, + report: (value: number) => void, + ): Promise => { + report(0.12); + report(0.13); + report(1.1); + throw new Error("Server unavailable"); + }, + ); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + const state = next[composerAttachmentUploadKey(environmentId, "file")]; + if (state?.status === "uploading") progress.push(state.progress); + }, + }); + queue.sync([request("file")]); + await queue.settled(); + queue.sync([request("file")]); + expect(upload).toHaveBeenCalledOnce(); + expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ + status: "failed", + reason: "Server unavailable", + }); + expect(progress).toEqual([0, 0.1, 1]); + upload.mockImplementationOnce(async () => true); + queue.retry(environmentId, "file"); + await queue.settled(); + expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" }); + queue.dispose(); + }); + + it("does not spin when an upload's draft was abandoned before persistence", async () => { + const upload = vi.fn(async () => false); + const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); + queue.sync([request("discarded")]); + await queue.settled(); + expect(upload).toHaveBeenCalledOnce(); + queue.dispose(); + }); +}); + +describe("draft upload scope and offline submission", () => { + it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { + expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); + expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); + expect( + composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]), + ).toBe(environmentId); + expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull(); + const colonEnvironment = EnvironmentId.make("a:vcs-status:b"); + expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment); + expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe( + colonEnvironment, + ); + }); + + it("allows offline queuing while a connected composer waits for upload or retry", () => { + const key = composerAttachmentUploadKey(environmentId, "file"); + const input = { + environmentId, + attachments: [request("file").attachment], + connected: true, + serverConfig: { + environment: { + capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } }, + }, + }, + states: {}, + }; + expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading"); + expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); + expect( + composerAttachmentUploadBlockReason({ + ...input, + states: { [key]: { status: "failed", reason: "Offline" } }, + }), + ).toBe("Retry or remove the failed attachment"); + expect( + composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts new file mode 100644 index 000000000000..071afefa4c7d --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts @@ -0,0 +1,193 @@ +import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; + +import type { DraftComposerAttachment } from "./composerImages"; + +export interface ComposerAttachmentUploadRequest { + readonly environmentId: EnvironmentId; + readonly attachment: DraftComposerAttachment; +} + +export type ComposerAttachmentUploadState = + | { readonly status: "uploading"; readonly progress: number } + | { readonly status: "ready" } + | { readonly status: "failed"; readonly reason: string }; + +export function composerAttachmentUploadKey( + environmentId: EnvironmentId, + attachmentId: string, +): string { + return `${environmentId}:${attachmentId}`; +} + +export function composerDraftEnvironmentId( + draftKey: string, + queuedMessages: ReadonlyArray<{ + readonly messageId: string; + readonly environmentId: EnvironmentId; + }>, +): EnvironmentId | null { + if (draftKey.startsWith("pending-task:")) { + return ( + queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey) + ?.environmentId ?? null + ); + } + const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey; + const separator = scope.lastIndexOf(":"); + return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null; +} + +type UploadServerConfig = { + readonly environment: { + readonly capabilities: Pick< + ServerConfig["environment"]["capabilities"], + "attachmentUploads" | "fileAttachments" + >; + }; +}; + +export function canUploadComposerAttachment( + attachment: DraftComposerAttachment, + config: UploadServerConfig | null | undefined, +): boolean { + const capabilities = config?.environment.capabilities; + return ( + capabilities?.attachmentUploads === true && + (attachment.type === "image" || + (capabilities.fileAttachments !== undefined && + attachment.sizeBytes <= + clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes))) + ); +} + +export function composerAttachmentUploadBlockReason(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly connected: boolean; + readonly serverConfig: UploadServerConfig | null; + readonly states: Readonly>; +}): string | null { + if (!input.connected) return null; + for (const attachment of input.attachments) { + if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue; + const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; + if (state?.status === "failed") return "Retry or remove the failed attachment"; + if (state?.status !== "ready") return "Attachment still uploading"; + } + return null; +} + +/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */ +export function createComposerAttachmentUploadQueue(options: { + readonly upload: ( + request: ComposerAttachmentUploadRequest, + signal: AbortSignal, + onProgress: (progress: number) => void, + ) => Promise; + readonly onChange: (states: Readonly>) => void; +}) { + const jobs = new Map< + string, + { readonly controller: AbortController; readonly done: Promise } + >(); + let desired = new Map(); + let states: Readonly> = {}; + let disposed = false; + + function setState(key: string, state: ComposerAttachmentUploadState | undefined) { + const previous = states[key]; + if ( + previous === state || + (previous?.status === "uploading" && + state?.status === "uploading" && + previous.progress === state.progress) + ) + return; + const next = { ...states }; + if (state) next[key] = state; + else delete next[key]; + states = next; + options.onChange(states); + } + + function pump() { + if (disposed) return; + for (const [key, request] of desired) { + if (jobs.size >= 3) break; + if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed") + continue; + const controller = new AbortController(); + setState(key, { status: "uploading", progress: 0 }); + // Publish the job before starting async work, including synchronous test transports. + const done = Promise.resolve() + .then(() => + options.upload(request, controller.signal, (progress) => { + if (controller.signal.aborted) return; + setState(key, { + status: "uploading", + progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20, + }); + }), + ) + .then((persisted) => { + if (!controller.signal.aborted && desired.has(key)) { + if (!persisted) desired.delete(key); + setState(key, persisted ? { status: "ready" } : undefined); + } + }) + .catch((error: unknown) => { + if (!controller.signal.aborted && desired.has(key)) { + setState(key, { + status: "failed", + reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.", + }); + } + }) + .finally(() => { + jobs.delete(key); + pump(); + }); + jobs.set(key, { controller, done }); + } + } + + return { + sync(requests: ReadonlyArray) { + if (disposed) return; + desired = new Map( + requests.map((request) => [ + composerAttachmentUploadKey(request.environmentId, request.attachment.id), + request, + ]), + ); + for (const [key, job] of jobs) { + if (!desired.has(key)) job.controller.abort(); + } + for (const key of Object.keys(states)) { + if (!desired.has(key)) setState(key, undefined); + } + for (const key of desired.keys()) { + if (!states[key]) setState(key, { status: "uploading", progress: 0 }); + } + pump(); + }, + retry(environmentId: EnvironmentId, attachmentId: string) { + const key = composerAttachmentUploadKey(environmentId, attachmentId); + if (states[key]?.status !== "failed") return; + setState(key, undefined); + pump(); + }, + /** Waits for the current transfers, useful for shutdown and focused verification. */ + async settled() { + while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done)); + }, + dispose() { + disposed = true; + desired.clear(); + for (const job of jobs.values()) job.controller.abort(); + states = {}; + options.onChange(states); + }, + }; +} diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 1a66c03c7060..77c2ec225564 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -23,6 +23,8 @@ import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { readonly id: string; readonly previewUri: string; + readonly uploadedAttachmentId?: string; + readonly uploadEnvironmentId?: EnvironmentId; } export interface DraftComposerFileAttachment { diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 75a84a906ee1..aac1abc4b81e 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -2,15 +2,14 @@ import { CommandId, MessageId, ThreadId, - type ChatFileAttachment, type ModelSelection, type ProjectId, type ProviderInteractionMode, type RuntimeMode, - type UploadChatImageAttachment, } from "@t3tools/contracts"; import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; +import type { UploadedMobileAttachment } from "./attachmentUpload"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -31,7 +30,7 @@ export interface ProjectThreadStartTurnSpec { readonly createdAt: string; readonly text: string; readonly attachments: ReadonlyArray; - readonly uploadedAttachments?: ReadonlyArray; + readonly uploadedAttachments?: ReadonlyArray; readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts new file mode 100644 index 000000000000..efc3fe1c39e5 --- /dev/null +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -0,0 +1,126 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; + +import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { + composerAttachmentUploadKey, + composerDraftEnvironmentId, + canUploadComposerAttachment, + createComposerAttachmentUploadQueue, + type ComposerAttachmentUploadState, +} from "../lib/composerAttachmentUploadQueue"; +import { appAtomRegistry } from "./atom-registry"; +import { useServerConfigs } from "./entities"; +import { flattenQueuedThreadMessages, threadOutboxManager } from "./thread-outbox"; +import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { + composerDraftsAtom, + ensureComposerDraftsLoaded, + flushComposerDrafts, + retainComposerAttachmentFileForPreview, + setComposerDraftAttachmentUpload, +} from "./use-composer-drafts"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; + +export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue"; + +export const composerAttachmentUploadsAtom = Atom.make< + Readonly> +>({}).pipe(Atom.keepAlive); +const uploadStateAtom = Atom.family((key: string) => + Atom.map(composerAttachmentUploadsAtom, (states) => states[key]), +); +let uploadQueue: ReturnType | null = null; + +export function useComposerAttachmentUploadState( + environmentId: EnvironmentId | undefined, + attachmentId: string, +) { + return useAtomValue( + uploadStateAtom(environmentId ? composerAttachmentUploadKey(environmentId, attachmentId) : ""), + ); +} + +export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) { + uploadQueue?.retry(environmentId, attachmentId); +} + +/** Runs outside mounted composers so a transfer can finish after navigation. */ +export function useComposerAttachmentUploadWorker() { + const drafts = useAtomValue(composerDraftsAtom); + const queuedMessages = useThreadOutboxMessages(); + const serverConfigs = useServerConfigs(); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const queueRef = useRef | null>(null); + + useEffect(() => { + ensureComposerDraftsLoaded(); + const queue = createComposerAttachmentUploadQueue({ + onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), + upload: async ({ environmentId, attachment }, signal, onProgress) => { + const release = + attachment.type === "file" + ? retainComposerAttachmentFileForPreview(attachment) + : undefined; + try { + const result = await prepareTurnAttachments({ + environmentId, + attachments: [attachment], + supportsImageUploads: true, + signal, + onUploadProgress: (_, progress) => onProgress(progress), + persistUploadedReferences: async ([uploaded]) => { + if (signal.aborted || !uploaded) return "abandon"; + const queued = flattenQueuedThreadMessages( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ); + let retained = false; + for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { + if ( + composerDraftEnvironmentId(key, queued) === environmentId && + draft.attachments.some((candidate) => candidate.id === attachment.id) + ) { + retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; + } + } + if (!retained) return "abandon"; + await flushComposerDrafts(); + return "persisted"; + }, + }); + return result.status === "ready"; + } finally { + release?.(); + } + }, + }); + queueRef.current = queue; + uploadQueue = queue; + return () => { + queue.dispose(); + if (uploadQueue === queue) uploadQueue = null; + queueRef.current = null; + }; + }, []); + + useEffect(() => { + const queued = flattenQueuedThreadMessages(queuedMessages); + const connected = new Set( + connectedEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); + const requests = Object.entries(drafts).flatMap(([key, draft]) => { + const environmentId = composerDraftEnvironmentId(key, queued); + if (environmentId === null || !connected.has(environmentId)) return []; + return draft.attachments + .filter((attachment) => + canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)), + ) + .map((attachment) => ({ environmentId, attachment })); + }); + queueRef.current?.sync(requests); + }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]); +} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 90b1ad262326..c5c6ca69f3c0 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -118,10 +118,12 @@ import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, + archiveCloudComposerDrafts, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, + composerCloudDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, @@ -136,7 +138,10 @@ import { resetComposerDraftsLoadState, retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, + restoreCloudComposerDrafts, setComposerDraftText, + setComposerDraftAttachmentUpload, + waitForComposerDraftsLoaded, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, undoComposerDraftMerge, @@ -157,6 +162,7 @@ afterEach(() => { composerDraftFileMocks.setOnWrite(null); composerDraftFileMocks.resetWrites(); appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); @@ -320,6 +326,150 @@ describe("mobile composer drafts", () => { expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); }); + it("retains offline image bytes and newer edits when an early upload finishes", async () => { + const key = "environment-1:thread-1"; + const image = { + id: "photo", + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///photo.png", + }; + const second = { ...image, id: "second", name: "second.png" }; + const uploaded = { + ...image, + uploadedAttachmentId: "pending-photo", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); + appendComposerDraftAttachments(key, [image]); + setComposerDraftText(key, "Edited while uploading"); + appendComposerDraftAttachments(key, [second]); + expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true); + await flushComposerDrafts(); + + appAtomRegistry.set(composerDraftsAtom, {}); + resetComposerDraftsLoadState(); + await waitForComposerDraftsLoaded(); + expect(getComposerDraftSnapshot(key)).toMatchObject({ + text: "Edited while uploading", + attachments: [uploaded, second], + }); + expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2); + }); + + it("cleans up an unreferenced image upload even when there is no local file URI", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const environmentId = EnvironmentId.make("environment-1"); + await releaseUnusedComposerAttachmentFiles([ + { + id: "photo", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///photo.png", + uploadedAttachmentId: "pending-photo", + uploadEnvironmentId: environmentId, + }, + ]); + expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ + "pending-photo", + ]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + }); + + it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => { + const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => load.mockRestore()); + await waitForComposerDraftsLoaded(); + const environmentId = EnvironmentId.make("cloud-environment"); + const key = `${environmentId}:thread-1`; + const file = { + id: "local-pdf", + type: "file" as const, + name: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/notes.pdf", + uploadEnvironmentId: environmentId, + uploadedAttachmentId: "pending-pdf", + }; + const queued = { + environmentId, + threadId: ThreadId.make("thread-2"), + messageId: MessageId.make("queued-1"), + commandId: CommandId.make("command-1"), + text: "Send later", + attachments: [file], + createdAt: "2026-08-31T12:00:00.000Z", + }; + appAtomRegistry.set(composerDraftsAtom, { + [key]: { text: "Unsent notes", attachments: [file] }, + "direct-environment:thread-1": DRAFT, + "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, + }); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); + await archiveCloudComposerDrafts("account-a", new Set([environmentId])); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + "direct-environment:thread-1": DRAFT, + }); + // The registry can remove the active outbox and drafts after the backup lands. + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + await clearComposerDraftsEnvironment(environmentId); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + resetComposerDraftsLoadState(); + await waitForComposerDraftsLoaded(); + await restoreCloudComposerDrafts("account-b"); + expect(getComposerDraftSnapshot(key).attachments).toEqual([]); + expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); + const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); + onTestFinished(() => enqueue.mockRestore()); + await restoreCloudComposerDrafts("account-a"); + expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); + expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); + expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); + expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); + const persisted = decodePersistedComposerState( + JSON.parse(composerDraftFileMocks.getDocument()), + ); + expect(persisted.drafts[key]?.attachments).toEqual([file]); + expect(persisted.cloudDrafts.accountId).toBe("account-a"); + }); + + it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { + const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => load.mockRestore()); + await waitForComposerDraftsLoaded(); + appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": DRAFT }); + composerDraftFileMocks.setWriteError(new Error("Storage is full")); + await expect( + archiveCloudComposerDrafts("account-a", new Set([EnvironmentId.make("environment-1")])), + ).rejects.toThrow(); + expect( + appAtomRegistry.get(composerCloudDraftsAtom).signedOut["account-a"]?.drafts[ + "environment-1:thread-1" + ], + ).toEqual(DRAFT); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + composerDraftFileMocks.setWriteError(null); + await archiveCloudComposerDrafts(null, new Set([EnvironmentId.make("environment-1")])); + expect( + decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).cloudDrafts + .signedOut["account-a"]?.drafts["environment-1:thread-1"], + ).toEqual(DRAFT); + }); + it("keeps a removed file until both playback and a share copy finish", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 28f3b9f7f99e..2a613b4914da 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -23,7 +23,14 @@ import { import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { + decodeQueuedThreadMessage, + encodeQueuedThreadMessage, + QueuedThreadMessageSchema, + type QueuedThreadMessage, +} from "./thread-outbox-model"; import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; +import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; @@ -93,6 +100,16 @@ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), + cloudAccountId: Schema.optional(Schema.String), + signedOutDrafts: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + drafts: Schema.Record(Schema.String, ComposerDraftSchema), + queuedMessages: Schema.Array(QueuedThreadMessageSchema), + }), + ), + ), }); const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( @@ -114,6 +131,21 @@ export const stickyComposerModelSelectionAtom = Atom.make Atom.withLabel("mobile:sticky-composer-model-selection"), ); +interface SignedOutDrafts { + readonly drafts: Record; + readonly queuedMessages: ReadonlyArray; +} + +interface ComposerCloudDraftState { + readonly accountId: string | null; + readonly signedOut: Record; +} + +export const composerCloudDraftsAtom = Atom.make({ + accountId: null, + signedOut: {}, +}).pipe(Atom.keepAlive); + let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; const persistenceQueue = new SerializedAsyncQueue(); @@ -156,6 +188,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; + readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); return { @@ -188,6 +221,18 @@ export function decodePersistedComposerState(value: unknown): { .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, + cloudDrafts: { + accountId: parsed.cloudAccountId ?? null, + signedOut: Object.fromEntries( + Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ + id, + { + drafts: saved.drafts, + queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), + }, + ]), + ), + }, }; } @@ -202,15 +247,18 @@ async function getComposerDraftsFile() { return new File(directory, COMPOSER_DRAFTS_FILE); } -async function loadPersistedComposerState(): Promise<{ - readonly drafts: Record; - readonly stickyModelSelection: ModelSelection | null; -}> { +async function loadPersistedComposerState(): Promise< + ReturnType +> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { - return { drafts: {}, stickyModelSelection: null }; + return { + drafts: {}, + stickyModelSelection: null, + cloudDrafts: { accountId: null, signedOut: {} }, + }; } operation = "read"; const raw = await file.text(); @@ -226,13 +274,18 @@ async function loadPersistedComposerState(): Promise<{ cause, }), ); - return { drafts: {}, stickyModelSelection: null }; + return { + drafts: {}, + stickyModelSelection: null, + cloudDrafts: { accountId: null, signedOut: {} }, + }; } } async function writePersistedComposerState( drafts: Record, stickyModelSelection: ModelSelection | null, + cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), ): Promise { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { @@ -245,6 +298,20 @@ async function writePersistedComposerState( schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), + ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), + ...(Object.keys(cloudDrafts.signedOut).length > 0 + ? { + signedOutDrafts: Object.fromEntries( + Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [ + id, + { + drafts: saved.drafts, + queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage), + }, + ]), + ), + } + : {}), } as const; const encoded = JSON.stringify(document); operation = "write"; @@ -290,6 +357,13 @@ export async function flushComposerDrafts(): Promise { } while (persistTimer !== null); } +function signedOutAttachmentOwners() { + return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [ + ...Object.values(saved.drafts), + ...saved.queuedMessages, + ]); +} + function isComposerAttachmentFileReferenced(fileUri: string): boolean { if (isComposerAttachmentFileRetained(fileUri)) { return true; @@ -299,7 +373,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); - return [...drafts, ...queuedMessages].some((owner) => + return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => attachment.type === "file" && @@ -316,10 +390,9 @@ function isComposerAttachmentUploadReferenced( const queuedMessages = Object.values( appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), ).flat(); - return [...drafts, ...queuedMessages].some((owner) => + return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => - attachment.type === "file" && attachment.uploadEnvironmentId === environmentId && attachment.uploadedAttachmentId === attachmentId, ), @@ -337,7 +410,6 @@ export async function releaseUnusedComposerAttachmentFiles( const uploadCandidates = new Map>(); for (const attachment of attachments) { if ( - attachment.type !== "file" || attachment.uploadEnvironmentId === undefined || attachment.uploadedAttachmentId === undefined ) { @@ -347,7 +419,7 @@ export async function releaseUnusedComposerAttachmentFiles( ids.add(attachment.uploadedAttachmentId); uploadCandidates.set(attachment.uploadEnvironmentId, ids); } - if (candidates.size === 0) { + if (candidates.size === 0 && uploadCandidates.size === 0) { return; } @@ -435,7 +507,11 @@ export async function releaseUnusedComposerAttachmentFiles( export function scheduleUnusedComposerAttachmentCleanup( attachments: ReadonlyArray, ): void { - if (!attachments.some((attachment) => attachment.type === "file")) { + if ( + !attachments.some( + (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined, + ) + ) { return; } void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => { @@ -443,7 +519,7 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } -/** Keeps previews usable after send/removal, then retries the normal ownership cleanup. */ +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ export function retainComposerAttachmentFileForPreview( attachment: DraftComposerFileAttachment, ): () => void { @@ -484,6 +560,7 @@ export function ensureComposerDraftsLoaded(): void { } loadPromise = loadPersistedComposerState() .then((persisted) => { + appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); if (Object.keys(persisted.drafts).length > 0) { const current = appAtomRegistry.get(composerDraftsAtom); appAtomRegistry.set(composerDraftsAtom, { @@ -520,6 +597,192 @@ export async function waitForComposerDraftsLoaded(): Promise { } } +export async function getComposerCloudAccountId(): Promise { + await waitForComposerDraftsLoaded(); + return appAtomRegistry.get(composerCloudDraftsAtom).accountId; +} + +/** Save an account's local work before its relay environments are removed. */ +export async function archiveCloudComposerDrafts( + accountId: string | null, + environmentIds: ReadonlySet, +): Promise { + await waitForComposerDraftsLoaded(); + if (!(await threadOutboxManager.load())) throw new Error("Could not preserve queued messages."); + await flushThreadOutbox(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const owner = accountId ?? cloud.accountId; + if (owner === null) return; + const queued = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ).flat(); + const current = appAtomRegistry.get(composerDraftsAtom); + const remaining = { ...current }; + const savedDrafts = { ...cloud.signedOut[owner]?.drafts }; + for (const [key, draft] of Object.entries(current)) { + const environmentId = composerDraftEnvironmentId(key, queued); + if (environmentId !== null && environmentIds.has(environmentId)) { + savedDrafts[key] = draft; + delete remaining[key]; + } + } + const savedMessages = new Map( + (cloud.signedOut[owner]?.queuedMessages ?? []).map((message) => [message.messageId, message]), + ); + for (const message of queued) { + if (environmentIds.has(message.environmentId)) savedMessages.set(message.messageId, message); + } + appAtomRegistry.set(composerDraftsAtom, remaining); + appAtomRegistry.set(composerCloudDraftsAtom, { + // Keep the owner through removal. A crash or failed cleanup can retry it + // on cold start before a different account activates. + accountId: owner, + signedOut: { + ...cloud.signedOut, + [owner]: { drafts: savedDrafts, queuedMessages: [...savedMessages.values()] }, + }, + }); + schedulePersistComposerState(); + await flushComposerDrafts(); +} + +function sameDraftAttachmentIds( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((attachment, index) => attachment.id === right[index]?.id) + ); +} + +/** An in-flight delivery can finish after sign-out took its snapshot. */ +export async function removeDeliveredCloudQueuedMessage( + message: QueuedThreadMessage, +): Promise { + await waitForComposerDraftsLoaded(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const signedOut = { ...cloud.signedOut }; + let changed = false; + for (const [accountId, saved] of Object.entries(signedOut)) { + const archived = saved.queuedMessages.find( + (candidate) => + candidate.environmentId === message.environmentId && + candidate.messageId === message.messageId, + ); + if ( + !archived || + archived.commandId !== message.commandId || + archived.threadId !== message.threadId || + archived.text !== message.text || + !sameDraftAttachmentIds(archived.attachments, message.attachments) + ) + continue; + // Upload ids may change during preparation; user edits must remain recoverable. + if ( + JSON.stringify([ + archived.modelSelection, + archived.runtimeMode, + archived.interactionMode, + archived.creation, + ]) !== + JSON.stringify([ + message.modelSelection, + message.runtimeMode, + message.interactionMode, + message.creation, + ]) + ) + continue; + const editorKey = `pending-task:${message.messageId}`; + const editor = saved.drafts[editorKey]; + if ( + editor && + (editor.text !== message.text || + !sameDraftAttachmentIds(editor.attachments, message.attachments) || + (editor.modelSelection !== undefined && + JSON.stringify(editor.modelSelection) !== JSON.stringify(message.modelSelection)) || + (editor.runtimeMode !== undefined && editor.runtimeMode !== message.runtimeMode) || + (editor.interactionMode !== undefined && + editor.interactionMode !== message.interactionMode) || + (editor.workspaceSelection !== undefined && + (editor.workspaceSelection.mode !== message.creation?.workspaceMode || + editor.workspaceSelection.branch !== message.creation?.branch || + editor.workspaceSelection.worktreePath !== message.creation?.worktreePath || + (editor.workspaceSelection.startFromOrigin ?? false) !== + (message.creation?.startFromOrigin ?? false)))) + ) + continue; + const drafts = { ...saved.drafts }; + delete drafts[editorKey]; + signedOut[accountId] = { + drafts, + queuedMessages: saved.queuedMessages.filter((candidate) => candidate !== archived), + }; + changed = true; + } + if (!changed) return; + appAtomRegistry.set(composerCloudDraftsAtom, { ...cloud, signedOut }); + schedulePersistComposerState(); + try { + await flushComposerDrafts(); + } catch (error) { + // The live outbox can still remove this acknowledged message. Keep the + // archive update pending so a later successful flush lands it too. + schedulePersistComposerState(); + throw error; + } +} + +/** Restores only this account, before its connections can deliver queued turns. */ +export async function restoreCloudComposerDrafts(accountId: string): Promise { + await waitForComposerDraftsLoaded(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const saved = cloud.signedOut[accountId]; + if (saved) { + if (!(await threadOutboxManager.load())) throw new Error("Could not restore queued messages."); + for (const message of saved.queuedMessages) { + const alreadyQueued = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ) + .flat() + .some((current) => current.messageId === message.messageId); + if (!alreadyQueued) await threadOutboxManager.enqueue(message); + } + updateComposerDrafts((current) => { + const restored = { ...current }; + for (const [key, draft] of Object.entries(saved.drafts)) { + const existing = current[key]; + const attachmentIds = new Set(existing?.attachments.map((attachment) => attachment.id)); + restored[key] = existing + ? { + ...draft, + ...existing, + text: mergeComposerDraftText(existing.text, draft.text), + // A concurrent import must not lose files, even above the send limit. + attachments: [ + ...existing.attachments, + ...draft.attachments.filter((attachment) => !attachmentIds.has(attachment.id)), + ], + importedShareIds: [ + ...new Set([ + ...(existing.importedShareIds ?? []), + ...(draft.importedShareIds ?? []), + ]), + ], + } + : draft; + } + return restored; + }); + } + const signedOut = { ...cloud.signedOut }; + delete signedOut[accountId]; + appAtomRegistry.set(composerCloudDraftsAtom, { accountId, signedOut }); + schedulePersistComposerState(); + await flushComposerDrafts(); +} + function updateComposerDrafts( update: (current: Record) => Record, ): void { @@ -655,6 +918,41 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) ); } +/** Stamps a finished upload without overwriting text, removals, or newer attachments. */ +export function setComposerDraftAttachmentUpload( + draftKey: string, + attachment: DraftComposerAttachment, +): boolean { + let previous: DraftComposerAttachment | undefined; + updateComposerDrafts((current) => { + const draft = current[draftKey]; + previous = draft?.attachments.find((candidate) => candidate.id === attachment.id); + if (!draft || !previous) return current; + if ( + previous.uploadedAttachmentId === attachment.uploadedAttachmentId && + previous.uploadEnvironmentId === attachment.uploadEnvironmentId + ) + return current; + return { + ...current, + [draftKey]: { + ...draft, + attachments: draft.attachments.map((candidate) => + candidate.id === attachment.id + ? { + ...candidate, + uploadedAttachmentId: attachment.uploadedAttachmentId, + uploadEnvironmentId: attachment.uploadEnvironmentId, + } + : candidate, + ), + }, + }; + }); + if (previous) scheduleUnusedComposerAttachmentCleanup([previous]); + return previous !== undefined; +} + export function updateComposerDraftSettings( draftKey: string, settings: Partial, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 603a38ecc81a..66e57802d1a6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -56,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadsAtom, +} from "./composer-attachment-uploads"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -178,6 +182,16 @@ export function useThreadComposerState() { const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); const attachments = draft.attachments; + if ( + composerAttachmentUploadBlockReason({ + environmentId: selectedThreadShell.environmentId, + attachments, + connected: selectedEnvironmentRuntime?.connectionState === "connected", + serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, + states: appAtomRegistry.get(composerAttachmentUploadsAtom), + }) !== null + ) + return null; if (text.length === 0 && attachments.length === 0) { return null; } @@ -298,7 +312,8 @@ export function useThreadComposerState() { ); return messageId; }, [ - selectedEnvironmentRuntime?.serverConfig?.providers, + selectedEnvironmentRuntime?.connectionState, + selectedEnvironmentRuntime?.serverConfig, selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index b991fa4ee791..d27f07962d60 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -193,6 +193,7 @@ beforeEach(() => { afterEach(() => { appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); harness.draftFile.setWriteError(null); harness.removePersistedFile.mockClear(); @@ -321,6 +322,72 @@ describe("thread outbox attachment preparation", () => { }); describe("thread outbox drain delivery cleanup", () => { + it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { + const message = queuedMessage({ messageId: "archive-write-failure", text: "Delivered" }); + await harness.manager.enqueue(message); + await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); + harness.draftFile.setWriteError(new Error("Draft storage unavailable")); + + await expect( + completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)), + ).resolves.toBe("removed"); + expect(remainingMessages()).toEqual([]); + + harness.draftFile.setWriteError(null); + await composerDrafts.flushComposerDrafts(); + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + composerDrafts.resetComposerDraftsLoadState(); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([]); + }); + + it.each([false, true])( + "does not restore a message delivered after the sign-out snapshot (outbox already cleared: %s)", + async (cleared) => { + const message = queuedMessage({ + messageId: "delivered-during-sign-out", + text: "Already delivered", + }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + await composerDrafts.archiveCloudComposerDrafts( + "account-a", + new Set([message.environmentId]), + ); + expect( + appAtomRegistry.get(composerDrafts.composerCloudDraftsAtom).signedOut["account-a"] + ?.queuedMessages, + ).toEqual([message]); + + if (cleared) await harness.manager.clearEnvironment(message.environmentId); + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe( + cleared ? "edited" : "removed", + ); + + // Restart before signing back in: the archived copy must be removed on disk too. + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { + accountId: null, + signedOut: {}, + }); + composerDrafts.resetComposerDraftsLoadState(); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([]); + }, + ); + + it("preserves an archived edit when an older payload finishes delivery", async () => { + const message = queuedMessage({ messageId: "edited-during-sign-out", text: "Original" }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + const edited = { ...message, text: "Keep this edit" }; + await harness.manager.update(edited); + await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); + await harness.manager.clearEnvironment(message.environmentId); + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([edited]); + }); + it("retries only cleanup after an acknowledged send removal fails", async () => { const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" }); const acknowledged = new Set([message.messageId]); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index c81cb87a893a..de6a538b52ef 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -52,6 +52,7 @@ import { getComposerDraftSnapshot, mergeComposerDraftContent, replaceComposerDraftAttachments, + removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, updateComposerDraftSettings, waitForComposerDraftsLoaded, @@ -114,7 +115,10 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma * `deliveryRevision` is the revision of the payload this attempt will send, * used for the delivery removal's compare-and-set. */ -export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThreadMessage): Promise< +export async function prepareQueuedMessageAttachments( + queuedMessage: QueuedThreadMessage, + supportsImageUploads = false, +): Promise< | { readonly status: "ready"; readonly prepared: PreparedTurnAttachments; @@ -135,6 +139,7 @@ export async function prepareQueuedMessageAttachments(queuedMessage: QueuedThrea const result = await prepareTurnAttachments({ environmentId: queuedMessage.environmentId, attachments: queuedMessage.attachments, + supportsImageUploads, persistUploadedReferences: async (draftAttachments) => { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return "abandon"; @@ -179,13 +184,19 @@ export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, ): Promise<"removed" | "edited" | "failed"> { - // The editor may have taken the entry while startTurn was in flight; its - // unsaved edits have not bumped the revision yet, so the CAS alone would - // let removal win and the editor would lose them once it saves. - if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { - return "edited"; - } try { + await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { + console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { + messageId: queuedMessage.messageId, + error, + }); + }); + // The editor may have taken the entry while startTurn was in flight; its + // unsaved edits have not bumped the revision yet, so the CAS alone would + // let removal win and the editor would lose them once it saves. + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + return "edited"; + } // Removal also releases the message's local attachment files. const removed = await removeThreadOutboxMessage( queuedMessage, @@ -221,6 +232,12 @@ export async function removeAcknowledgedExistingThreadMessage( acknowledgedMessageIds: Set, ): Promise { try { + await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { + console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { + messageId: queuedMessage.messageId, + error, + }); + }); const removed = await removeThreadOutboxMessage(queuedMessage); if (removed) { acknowledgedMessageIds.delete(queuedMessage.messageId); @@ -453,15 +470,10 @@ async function preserveUploadedAttachmentsForEditor( const draftKey = `pending-task:${originalMessage.messageId}`; const draft = getComposerDraftSnapshot(draftKey); const uploadedById = new Map( - uploadedMessage.attachments - .filter((attachment) => attachment.type === "file") - .map((attachment) => [attachment.id, attachment] as const), + uploadedMessage.attachments.map((attachment) => [attachment.id, attachment] as const), ); let changed = false; const nextAttachments = draft.attachments.map((attachment) => { - if (attachment.type !== "file") { - return attachment; - } const uploaded = uploadedById.get(attachment.id); if ( !uploaded?.uploadedAttachmentId || @@ -672,7 +684,11 @@ export function useThreadOutboxDrain(): void { let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { - const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); + const preparedResult = await prepareQueuedMessageAttachments( + queuedMessage, + serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities + .attachmentUploads === true, + ); if (preparedResult.status === "abandoned") { return true; } @@ -744,6 +760,7 @@ export function useThreadOutboxDrain(): void { startTurn, updateThreadMetadata, restoreQueuedMessage, + serverConfigs, ], ); @@ -761,7 +778,11 @@ export function useThreadOutboxDrain(): void { let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; try { - const preparedResult = await prepareQueuedMessageAttachments(queuedMessage); + const preparedResult = await prepareQueuedMessageAttachments( + queuedMessage, + serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities + .attachmentUploads === true, + ); if (preparedResult.status === "abandoned") { return true; } @@ -838,7 +859,7 @@ export function useThreadOutboxDrain(): void { } return false; }, - [makeDeliveryHelpers, restoreQueuedMessage, startTurn], + [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn], ); useEffect(() => { diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 46fe0c82716a..1b686c365770 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on removal the registry clears its cache and calls the platform implementation, so web clears composer drafts and mobile clears drafts plus the thread outbox. +Mobile cloud sign-out first saves relay drafts and queued messages in the local +composer store under the owning account. These saved copies retain attachment +files during cleanup and remain outside the active composer and upload queue. +Signing back into that account restores them before relay credentials activate. +Directly paired environments keep their drafts and outbox when cloud sign-out runs. + +Mobile composer attachments upload over HTTP while their environment is connected, +with at most three concurrent transfers. Drafts retain local image data or an owned +file URI alongside the pending upload ID. Sending verifies and reuses that ID, or +uploads the local bytes again if it expired. Disconnecting cancels active transfers +without discarding drafts; reconnecting resumes preparation. Older servers without +attachment-upload support continue to receive inline images. + ## Source Boundaries Applications must import explicit package subpaths; the package intentionally diff --git a/docs/user/composer.md b/docs/user/composer.md index 4e251d5d6d58..86525f7e1869 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -13,13 +13,16 @@ attach videos, text files, PDFs, ZIP archives, and other files. Each file can be by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files upload directly to the environment, where your agent can read, copy, or edit them by their file path. -On web and desktop, attachments upload as soon as you add them. The send button becomes available -after every upload finishes. Failed uploads can be retried or removed. On mobile, tap **+** to open +Attachments upload as soon as you add them while connected to a server that supports uploads. +The send button becomes available after every upload finishes. Failed uploads can be retried or +removed. On mobile, tap **+** to open the photo library from either the compact or expanded composer. When the connected server supports file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**. Videos use the server's file upload limit. You can also share photos, videos, and files into -T3 Code from other apps through the system share sheet. Mobile uploads happen when the message -sends, so queued messages keep their files until they deliver. Select a received file on mobile +T3 Code from other apps through the system share sheet. Mobile keeps a local copy of each draft +attachment, so you can still preview it and queue messages while offline. Uploads resume when +you reconnect. Drafts and queued messages survive app restarts; signing out of T3 Connect keeps +them on your device until you sign back into the same account. Select a received file on mobile to preview it or open the system share options. Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail From 9ecfc07a8b3acadd1612665b6e24425f625480de Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 31 Aug 2026 22:46:21 -0400 Subject: [PATCH 23/42] fix(chat): keep agent activity visible between actions (#8984) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../chat/MessagesTimeline.logic.test.ts | 49 ++++++----- .../components/chat/MessagesTimeline.logic.ts | 68 +++++++++------ .../components/chat/MessagesTimeline.test.tsx | 13 +-- .../src/components/chat/MessagesTimeline.tsx | 86 +++++++++++-------- 4 files changed, 123 insertions(+), 93 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 9e18f8f00443..d9bfdae04d49 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -826,10 +826,11 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", + "thinking-indicator-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: true }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the active in-progress turn", () => { @@ -886,18 +887,18 @@ describe("deriveMessagesTimelineRows", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { - id: "completed-command-entry", + id: "running-command-entry", kind: "work", createdAt: "2026-01-01T00:00:05Z", entry: { - id: "completed-command", + id: "running-command", createdAt: "2026-01-01T00:00:05Z", turnId: "turn-1" as never, - label: "Ran rg", + label: "Running rg", command: "rg toolCall", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "completed" as const, + toolLifecycleStatus: "inProgress" as const, }, }, { @@ -916,18 +917,18 @@ describe("deriveMessagesTimelineRows", () => { }, }, { - id: "running-command-entry", + id: "completed-command-entry", kind: "work", createdAt: "2026-01-01T00:00:07Z", entry: { - id: "running-command", + id: "completed-command", createdAt: "2026-01-01T00:00:07Z", turnId: "turn-1" as never, - label: "Running tests", + label: "Ran tests", command: "vp test run", requestKind: "command", tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, + toolLifecycleStatus: "completed" as const, }, }, ], @@ -944,13 +945,13 @@ describe("deriveMessagesTimelineRows", () => { }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, groupedEntries: [ - { id: "completed-command" }, - { id: "completed-edit" }, { id: "running-command" }, + { id: "completed-edit" }, + { id: "completed-command" }, ], }); }); @@ -1195,7 +1196,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("keeps the latest completed tool call live while the turn is running", () => { + it("shows thinking after the latest tool call completes while the turn is running", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -1226,11 +1227,9 @@ describe("deriveMessagesTimelineRows", () => { revertTurnCountByUserMessageId: new Map(), }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ - entry: { id: "latest-command" }, - groupedEntries: [{ id: "latest-command" }], - }); + expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1378,7 +1377,7 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.showAssistantMeta).toBe(false); expect(assistantRow?.showAssistantCopyButton).toBe(false); - expect(rows.find((row) => row.kind === "working")).toMatchObject({ showThinking: false }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); }); it.each([ @@ -1547,7 +1546,7 @@ describe("deriveMessagesTimelineRows", () => { }); describe("computeStableMessagesTimelineRows", () => { - it.each(["", " \n"])("replaces Thinking when assistant content grows from %j", (text) => { + it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => { const startedAt = "2026-01-01T00:00:00Z"; const turnId = TurnId.make("turn-1"); const input = { @@ -1588,11 +1587,11 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialWorking = initial.byId.get("working-indicator-row"); - const updatedWorking = updated.byId.get("working-indicator-row"); - expect(initialWorking).toMatchObject({ showThinking: true }); - expect(updatedWorking).toMatchObject({ showThinking: false }); - expect(updatedWorking).not.toBe(initialWorking); + const initialThinking = initial.byId.get("thinking-indicator-row"); + const updatedThinking = updated.byId.get("thinking-indicator-row"); + expect(initialThinking).toMatchObject({ kind: "thinking" }); + expect(updatedThinking).toBe(initialThinking); + expect(updated.result.at(-1)).toBe(updatedThinking); }); it("returns the previous result when row order and content are unchanged", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c190643ee7f3..66a3b3b67440 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -193,6 +193,7 @@ export type MessagesTimelineRow = groupedEntries: WorkLogEntry[]; groupId: string; expanded: boolean; + active: boolean; } | { kind: "work-toggle"; @@ -235,7 +236,11 @@ export type MessagesTimelineRow = kind: "working"; id: string; createdAt: string | null; - showThinking: boolean; + } + | { + kind: "thinking"; + id: string; + createdAt: string | null; }; export interface StableMessagesTimelineRowsState { @@ -512,6 +517,14 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; } +function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { + return ( + entry.toolLifecycleStatus === "inProgress" || + entry.sourceActivityKind === "task.progress" || + (entry.toolLifecycleStatus === undefined && workLogEntryIsToolLike(entry)) + ); +} + /** * Settled turns keep only their terminal assistant message visible. * Everything before it folds behind a "Worked for ..." row anchored at the @@ -702,24 +715,6 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId !== null && entry.toolLifecycleStatus === "inProgress" && entry.turnId === unsettledTurnId; - const activeEntries = input.isWorking - ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) - : []; - const activeTurnHasVisibleContent = activeEntries.some((entry) => { - if (entry.kind === "message") { - return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; - } - if (entry.kind === "work") { - return ( - entry.entry.agentSpawn === undefined && - workLogEntryIsToolLike(entry.entry) && - entry.entry.toolLifecycleStatus === "inProgress" - ); - } - if (entry.kind === "proposed-plan") return true; - return false; - }); - const activeToolEntries: Array> = []; for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { const entry = input.timelineEntries[index]!; @@ -733,40 +728,48 @@ export function deriveMessagesTimelineRows(input: { } activeToolEntries.unshift(entry); } - const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id)); const visibleActiveToolEntries = omitSupersededLifecycleMarkers( activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)), (entry) => entry.entry, ); const activeWorkAnchor = activeToolEntries[0]; - const latestActiveToolEntry = visibleActiveToolEntries.at(-1); - const activeWorkPlacementEntryId = latestActiveToolEntry?.id; + const latestVisibleToolEntry = visibleActiveToolEntries.at(-1); + const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => + workEntryIsActiveTurnActivity(entry.entry), + ); + const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestActiveToolEntry + activeWorkAnchor && displayedToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: latestActiveToolEntry.entry, + entry: displayedToolEntry.entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + active: latestRunningToolEntry !== undefined, }; })() : null; + const activeWorkEntryIds = new Set( + activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id), + ); const appendWorkingRow = () => { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, - showThinking: activeWorkRow === null && !activeTurnHasVisibleContent, }); }; + let hasLiveWorkRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); + hasLiveWorkRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -864,7 +867,9 @@ export function deriveMessagesTimelineRows(input: { groupedEntries: visibleGroupedEntries, groupId, expanded, + active: true, }); + hasLiveWorkRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -966,6 +971,13 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } + if (input.isWorking && !hasLiveWorkRow) { + nextRows.push({ + kind: "thinking", + id: "thinking-indicator-row", + createdAt: input.activeTurnStartedAt, + }); + } return nextRows; } @@ -996,9 +1008,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return ( - a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking - ); + case "thinking": + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; @@ -1023,6 +1034,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.createdAt === bw.createdAt && a.groupId === bw.groupId && a.expanded === bw.expanded && + a.active === bw.active && Equal.equals(a.entry, bw.entry) && Equal.equals(a.groupedEntries, bw.groupedEntries) ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 9a8ad8f6c038..024dfe69d278 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1308,7 +1308,7 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps terminal command copy live while the parent turn is active", () => { + it("keeps declined command copy visible while thinking continues", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-failed", + id: "entry-declined", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-failed", + id: "work-declined", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-failed", + toolCallId: "call-declined", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "failed", + toolLifecycleStatus: "declined", }, }, ]} />, ); - expect(markup).toContain("Running pnpm"); + expect(markup).toContain("Declined pnpm"); + expect(markup).toContain("Thinking"); expect(markup).toContain("tool call failed"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 81ab67f6ffe3..d49c355e5b3b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -981,7 +981,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" + : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && @@ -1013,6 +1013,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} + {row.kind === "thinking" ? : null}
); }); @@ -1330,34 +1331,36 @@ function ProposedPlanTimelineRow({ function WorkingTimelineRow({ row }: { row: Extract }) { const { isPreparingWorktree } = use(TimelineRowActivityCtx); return ( -
-
-
- - {isPreparingWorktree ? ( - <> - Setting up worktree… - Setting up worktree… - - ) : row.createdAt ? ( - <> - Working for - - ) : ( - "Working..." - )} - -
+
+
+ + {isPreparingWorktree ? ( + <> + Setting up worktree… + Setting up worktree… + + ) : row.createdAt ? ( + <> + Working for + + ) : ( + "Working..." + )} +
- {row.showThinking ? ( - // Reserve the activity row during setup so the handoff keeps the same height. -
- {isPreparingWorktree ? null : } -
- ) : null} +
+ ); +} + +function ThinkingTimelineRow() { + const { isPreparingWorktree } = use(TimelineRowActivityCtx); + // Reserve the activity row during setup so the handoff keeps the same height. + return ( +
+ {isPreparingWorktree ? null : }
); } @@ -1514,7 +1517,7 @@ function LiveActivityContent({ function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot); + const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( @@ -1525,7 +1528,18 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > - + {row.active ? ( + + ) : ( +
+ +
+ )} ); } @@ -2205,14 +2219,18 @@ function workEntryRawCommand( function liveWorkEntryLabel( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, + active: boolean, ): string { const command = workEntry.command?.trim(); if (command) { - // This row describes the active parent turn, not the command lifecycle. - // Keep its live "Running" copy until the turn or contiguous tool run settles. const program = commandProgramName(command); - if (program) return `Running ${program}`; - return "Running command"; + const verb = active + ? "Running" + : workEntry.toolLifecycleStatus === "declined" + ? "Declined" + : "Ran"; + if (program) return `${verb} ${program}`; + return `${verb} command`; } return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); From c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:07:42 +0300 Subject: [PATCH 24/42] fix(server): isolate remote web session cookies (#8085) Co-authored-by: Julius Marminge --- apps/server/src/auth/EnvironmentAuth.test.ts | 16 +++ apps/server/src/auth/EnvironmentAuth.ts | 50 +++++++-- .../src/auth/EnvironmentAuthAdmin.test.ts | 2 + .../src/auth/EnvironmentAuthPolicy.test.ts | 6 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 3 + apps/server/src/auth/SessionStore.test.ts | 37 ++++++- apps/server/src/auth/SessionStore.ts | 12 ++- apps/server/src/auth/http.ts | 49 ++++++--- apps/server/src/auth/utils.test.ts | 65 +++++++---- apps/server/src/auth/utils.ts | 35 ++++-- apps/server/src/bin.test.ts | 29 ++++- apps/server/src/cli/connect.ts | 5 +- apps/server/src/cli/pair.test.ts | 25 ++++- .../src/environment/ServerEnvironment.test.ts | 77 ++++++++++++- .../src/environment/ServerEnvironment.ts | 102 ++++++++++++++---- apps/server/src/server.test.ts | 47 ++++++++ apps/server/src/server.ts | 7 +- docs/internals/remote.md | 4 + 18 files changed, 484 insertions(+), 87 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 440efcee51ee..6e5f22fa3af6 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("prefers a bearer token over a stale legacy cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const bearer = yield* serverAuth.issueSession(); + const verified = yield* serverAuth.authenticateHttpRequest({ + cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" }, + headers: { authorization: `Bearer ${bearer.token}` }, + } as never); + + expect(verified.sessionId).toBe(bearer.sessionId); + }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))), + ); + it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index f5d244dd9667..08838cb7b780 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -30,6 +30,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } +export function selectRequestCredential( + request: HttpServerRequest.HttpServerRequest, + cookieName: string, + legacyCookieName: string | undefined, +) { + const cookieToken = request.cookies[cookieName]; + if (cookieToken !== undefined) { + return { token: cookieToken, source: "cookie" } as const; + } + + const bearerToken = parseBearerToken(request); + if (bearerToken !== null) { + return { token: bearerToken, source: "bearer" } as const; + } + + const dpopToken = parseDpopToken(request); + if (dpopToken !== null) { + return { token: dpopToken, source: "dpop" } as const; + } + + const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; + if (legacyToken !== undefined) { + return { token: legacyToken, source: "legacy-cookie" } as const; + } + + return undefined; +} + export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -600,17 +629,19 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const cookieToken = request.cookies[sessions.cookieName]; - const bearerToken = parseBearerToken(request); - const dpopToken = parseDpopToken(request); - const credential = cookieToken ?? bearerToken ?? dpopToken; - if (!credential) { + const credential = selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - return authenticateToken(credential).pipe( + const dpopToken = parseDpopToken(request); + return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { - if (!dpopToken || dpopToken !== credential) { + if (!dpopToken || dpopToken !== credential.token) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", @@ -1003,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); -export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); +export const runtimeLayer = layer.pipe( + Layer.provideMerge(storageLayer), + Layer.provideMerge(ServerEnvironment.identityLayer), +); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 03009270e15c..331a722534b4 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = ( EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), ); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 8e4c21710880..982ff397db40 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -4,12 +4,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; const makeEnvironmentAuthPolicyLayer = ( overrides?: Partial, ) => EnvironmentAuthPolicy.layer.pipe( + Layer.provide(ServerEnvironment.identityLayer), Layer.provide( Layer.effect( ServerConfig.ServerConfig, @@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("remote-reachable"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ @@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 9945c69067d7..446b8a8bba95 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< @@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const isRemoteReachable = isRemoteReachableHost(config.host); const policy = @@ -42,6 +44,7 @@ export const make = Effect.gen(function* () { port: config.port, host: config.host, instanceKey: config.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1fb01c1f0002..aa3b2d199148 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = ( - overrides?: Partial>, -) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { @@ -27,12 +27,19 @@ const makeServerConfigLayer = ( }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); +const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(environmentId), + }); + const makeSessionStoreLayer = ( - overrides?: Partial>, + overrides?: Partial, + environmentId = EnvironmentId.make("test-environment"), ) => SessionStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(environmentId)), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), Layer.provide(makeServerConfigLayer()), ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect("keys remote cookies by environment identity instead of state directory", () => + Effect.gen(function* () { + const cookieName = (stateDir: string, environmentId: EnvironmentId) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + return sessions.cookieName; + }).pipe( + Effect.provide( + makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId), + ), + ); + + const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one")); + const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one")); + const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two")); + + expect(moved).toBe(original); + expect(other).not.toBe(original); + }), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index cdcd4a1ac198..d4fbe445edf6 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -21,11 +21,13 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, + resolveLegacySessionCookieName, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, @@ -360,6 +362,7 @@ export class SessionStore extends Context.Service< SessionStore, { readonly cookieName: string; + readonly legacyCookieName: string | undefined; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; @@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit): AuthCli export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); - const cookieName = resolveSessionCookieName({ + const cookieInput = { mode: serverConfig.mode, port: serverConfig.port, host: serverConfig.host, instanceKey: serverConfig.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: serverConfig.devUrl !== undefined, - }); + } as const; + const cookieName = resolveSessionCookieName(cookieInput); + const legacyCookieName = resolveLegacySessionCookieName(cookieInput); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -930,6 +937,7 @@ export const make = Effect.gen(function* () { return SessionStore.of({ cookieName, + legacyCookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 58277141a946..cc74966c41e2 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => + Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, token, { + expires: DateTime.toDate(expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe( + Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), + Effect.flatMap((cookies) => + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), + ), + ), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; - return yield* serverAuth.getSessionState(request); + const result = yield* serverAuth.getSessionState(request); + const credential = EnvironmentAuth.selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if ( + credential?.source === "legacy-cookie" && + result.authenticated && + result.sessionMethod === "browser-session-cookie" && + result.expiresAt + ) { + yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); + yield* appendCredentialResponseHeaders; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - const sessionCookies = yield* Effect.fromResult( - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); - - yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), + yield* appendSessionCookie( + sessions.cookieName, + result.sessionToken, + result.response.expiresAt, ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index edc58f71131f..aebc9df5f437 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -64,6 +64,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-one", + environmentId: "environment-one", development: true, }); const second = resolveSessionCookieName({ @@ -71,6 +72,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-two", + environmentId: "environment-two", development: true, }); @@ -79,25 +81,48 @@ describe("session cookie isolation", () => { expect(first).not.toBe(second); }); - it("keeps the hosted web cookie stable across server instances", () => { - expect( - resolveSessionCookieName({ - mode: "web", - port: 8080, - host: "0.0.0.0", - instanceKey: "/srv/release-a", - development: false, - }), - ).toBe("t3_session"); - expect( - resolveSessionCookieName({ - mode: "web", - port: 9090, - host: "app.example.com", - instanceKey: "/srv/release-b", - development: false, - }), - ).toBe("t3_session"); + it("isolates remote web servers by server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 3773, + host: "192.168.1.50", + instanceKey: "/srv/t3-one", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "192.168.1.50", + instanceKey: "/srv/t3-two", + environmentId: "environment-two", + development: false, + }); + + expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps a remote web server cookie stable across port changes", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + + expect(first).toBe(second); }); it("retains desktop port scoping", () => { @@ -107,6 +132,7 @@ describe("session cookie isolation", () => { port: 3773, host: "127.0.0.1", instanceKey: "/tmp/desktop", + environmentId: "environment-one", development: true, }), ).toBe("t3_session_3773"); @@ -119,6 +145,7 @@ describe("session cookie isolation", () => { port: 5775, host: "0.0.0.0", instanceKey: "/tmp/t3-wildcard-dev", + environmentId: "environment-one", development: true, }), ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b01f4..30d59d654010 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session"; * clobbers the first's session and both sides see "Invalid session token * signature" until someone clears cookies by hand. * - * Two populations qualify, for the same reason but from different causes: + * Remote web servers use their persisted environment identity and omit the + * port, so the name survives state-directory moves and public port changes. * - * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. - * - **Desktop**, which scans upward from 3773 for a free port and binds + * Desktop scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. - * - * Hosted deployments keep the stable production name: their public port can - * change between releases, and scoping it would log every user out. */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; readonly host: string | undefined; readonly instanceKey: string; + readonly environmentId: string; readonly development: boolean; }): string { if (input.mode === "desktop") { return `${SESSION_COOKIE_NAME}_${input.port}`; } + const instanceHash = NodeCrypto.createHash("sha256") + .update( + !input.development && isRemoteReachableHost(input.host) + ? input.environmentId + : input.instanceKey, + ) + .digest("hex") + .slice(0, 12); + if (!input.development && isRemoteReachableHost(input.host)) { - return SESSION_COOKIE_NAME; + return `${SESSION_COOKIE_NAME}_${instanceHash}`; } // Cookies are scoped by host, not port. Loopback development servers need an // instance-specific name or parallel agents overwrite each other's session, // and a server that later reuses the port receives a token signed elsewhere. - const instanceHash = NodeCrypto.createHash("sha256") - .update(input.instanceKey) - .digest("hex") - .slice(0, 12); return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; } +export function resolveLegacySessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly host: string | undefined; + readonly development: boolean; +}): string | undefined { + return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) + ? SESSION_COOKIE_NAME + : undefined; +} + export function isRemoteReachableHost(host: string | undefined): boolean { if (host === "0.0.0.0" || host === "::" || host === "[::]") { return true; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fcb662b9b780..4cccfc6a1a7a 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -13,6 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, +} from "./cloud/serviceProtocol.ts"; import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import packageJson from "../package.json" with { type: "json" }; + const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const DisconnectedLauncherChildLayer = Layer.mergeAll( + Layer.succeed(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -127,6 +151,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Layer.provideMerge( EnvironmentAuth.layer.pipe( Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(ServerSecretStore.layer), ), ), @@ -237,7 +262,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(status.linked, false); assert.equal(status.cloudUserId, null); assert.equal(status.relayUrl, null); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("reports actionable human-readable headless connect state", () => @@ -408,7 +433,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { "relay:write", ]); assert.equal("token" in (listed[0] ?? {}), false); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("rejects invalid ttl values before running auth commands", () => diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 74f469364aee..3f8e1d123da5 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -337,7 +337,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f return { status: "not-authenticated" } satisfies RelayUnlinkResult; } - const environment = yield* ServerEnvironment.ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; const environmentId = yield* environment.getEnvironmentId; const relayUrl = yield* relayUrlConfig; const httpClient = yield* HttpClient.HttpClient; @@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* , options?: { readonly quietLogs?: boolean; @@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* { assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); }), - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe( + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), + ), ); it.effect("pairs through the recorded dev web URL for dev servers", () => diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 9dc1a8eb5b0c..32006f691ed7 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect.each([ + { name: "missing", content: undefined }, + { name: "empty", content: "" }, + { name: "whitespace-only", content: " \t\n" }, + ])("concurrent initializers recover a $name environment id file", ({ content }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-concurrent-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + if (content !== undefined) { + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content); + } + const bothGenerated = yield* Deferred.make(); + const bothReadEmpty = yield* Deferred.make(); + const firstInitialized = yield* Deferred.make(); + let remaining = 2; + let emptyReads = 0; + const readIdentity = Effect.gen(function* () { + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + return yield* identity.getEnvironmentId; + }).pipe( + Effect.tap(() => Deferred.succeed(firstInitialized, undefined)), + Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path) => + fileSystem.readFileString(path).pipe( + Effect.tap( + Effect.fn(function* (value) { + if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) { + return; + } + // Both observe the empty file, but one repairs it after the other has finished. + if (++emptyReads === 2) { + yield* Deferred.succeed(bothReadEmpty, undefined); + yield* Deferred.await(firstInitialized); + } else { + yield* Deferred.await(bothReadEmpty); + } + }), + ), + ), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + randomUUIDv4: Effect.gen(function* () { + const id = yield* crypto.randomUUIDv4; + if (--remaining === 0) { + yield* Deferred.succeed(bothGenerated, undefined); + } + yield* Deferred.await(bothGenerated); + return id; + }), + }), + ); + + const [first, second] = yield* Effect.all([readIdentity, readIdentity], { + concurrency: "unbounded", + }); + const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath); + + expect(first).toBe(second); + expect(persisted.trim()).toBe(first); + }), + ); + it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -153,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; + const tempPath = `${environmentIdPath}.tmp`; const methodByOperation = { check: "exists", read: "readFileString", @@ -172,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { exists: () => operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), readFileString: () => Effect.fail(cause), + makeTempFileScoped: () => Effect.succeed(tempPath), writeFileString: (path) => { writeAttempts.push(path); return Effect.fail(cause); @@ -201,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(error.message).toBe( `Server environment ID ${operation} failed at '${environmentIdPath}'.`, ); - expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c3a0d2a9637..11d4078de60e 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -24,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( "ServerEnvironmentIdPersistenceError", { - operation: Schema.Literals(["check", "read", "write"]), + operation: Schema.Literals(["check", "read", "write", "initialize"]), environmentIdPath: Schema.String, - cause: Schema.Defect(), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { + if (this.operation === "initialize") { + return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`; + } return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; } } @@ -42,6 +45,13 @@ export class ServerEnvironment extends Context.Service< } >()("t3/environment/ServerEnvironment") {} +export class ServerEnvironmentIdentity extends Context.Service< + ServerEnvironmentIdentity, + { + readonly getEnvironmentId: Effect.Effect; + } +>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} + function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (platform) { case "darwin": @@ -68,14 +78,10 @@ function platformArch( } } -export const make = Effect.gen(function* () { +const makeIdentity = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -107,17 +113,42 @@ export const make = Effect.gen(function* () { return raw.length > 0 ? raw : null; }); - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( - Effect.mapError( - (cause) => - new ServerEnvironmentIdPersistenceError({ - operation: "write", - environmentIdPath: serverConfig.environmentIdPath, - cause, - }), - ), - ); + const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")( + function* (value: string, mode: "create" | "recover") { + const destinationPath = + mode === "recover" + ? `${serverConfig.environmentIdPath}.recovery` + : serverConfig.environmentIdPath; + const tempPath = yield* fileSystem.makeTempFileScoped({ + directory: serverConfig.stateDir, + prefix: ".environment-id-", + }); + yield* fileSystem.writeFileString(tempPath, `${value}\n`); + // Publish the completed file without replacing an ID created by another process. + yield* fileSystem + .link(tempPath, destinationPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), + ), + ); + if (mode === "recover") { + // Keep the recovery ID so delayed initializers also publish the same winner. + yield* fileSystem.remove(tempPath); + yield* fileSystem.copyFile(destinationPath, tempPath); + yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath); + } + }, + Effect.scoped, + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); const environmentIdRaw = yield* Effect.gen(function* () { const persisted = yield* readPersistedEnvironmentId; @@ -126,11 +157,35 @@ export const make = Effect.gen(function* () { } const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; + yield* persistEnvironmentId(generated, "create"); + let winner = yield* readPersistedEnvironmentId; + if (winner === null) { + yield* persistEnvironmentId(generated, "recover"); + winner = yield* readPersistedEnvironmentId; + } + if (winner === null) { + return yield* new ServerEnvironmentIdPersistenceError({ + operation: "initialize", + environmentIdPath: serverConfig.environmentIdPath, + }); + } + return winner; }); const environmentId = EnvironmentId.make(environmentIdRaw); + return ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); +}); + +export const make = Effect.gen(function* () { + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const identity = yield* ServerEnvironmentIdentity; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const launcher = yield* resolveServiceLauncherMode(); @@ -179,10 +234,15 @@ export const make = Effect.gen(function* () { }); }); +export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); + /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must * provide the external platform services, a ServerConfig, and the * ServerSecretStore backing the descriptor's publishing capability. */ -export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(ServerEnvironment, make).pipe( + Layer.provideMerge(identityLayer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 15c16930350e..0780af4d1b4a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -291,6 +291,11 @@ const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + }), + ), ); const makeBrowserOtlpPayload = (spanName: string) => @@ -1674,6 +1679,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("migrates a valid legacy remote-web session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const currentCookie = cookie?.split(";")[0] ?? ""; + const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session="); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { cookie: legacyCookie }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.equal(response.headers["set-cookie"], cookie); + assert.equal(response.headers["cache-control"], "no-store"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect.each(["cookie", "bearer"])( + "does not migrate a stale legacy cookie when %s auth succeeds", + (source) => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const sessionCookie = cookie?.split(";")[0] ?? ""; + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: + source === "cookie" + ? { cookie: `${sessionCookie}; t3_session=stale` } + : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.isUndefined(response.headers["set-cookie"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8c6d4253acaa..9a05fd18517e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -362,8 +362,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); +const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerEnvironmentLayerLive), Layer.provide(ServerSecretStore.layer), ); @@ -418,7 +423,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(ServerEnvironment.layer), + Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( diff --git a/docs/internals/remote.md b/docs/internals/remote.md index afce95f725bc..65416a19e967 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `/environment-id.recovery` file so concurrent and delayed repairs choose +the same ID. Existing nonempty ID files remain authoritative. + ### Known environments and connection targets A saved client-side entry for an environment the client knows how to reach. It is not From 42a8fd5103b4af57b16be721a9864ca3a5c18c5b Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 31 Aug 2026 23:35:28 -0400 Subject: [PATCH 25/42] feat(pull-requests): link GitHub references in markdown (#8812) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatMarkdown.tsx | 30 ++++++- .../pullRequest/PullRequestDetailPanel.tsx | 5 +- .../pullRequest/PullRequestMarkdown.tsx | 12 ++- .../pullRequest/pullRequestMarkdown.logic.ts | 49 ++++++++++ apps/web/src/vendor/mdast-find-and-replace.ts | 90 +++++++++++++++++++ 5 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/vendor/mdast-find-and-replace.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7773338a7459..d23d6bef4868 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -179,6 +179,7 @@ interface ChatMarkdownProps { onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + extraRemarkPlugins?: NonNullable; } export function canUseMarkdownFileShellActions( @@ -211,6 +212,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_REMARK_PLUGINS: NonNullable = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, @@ -360,6 +362,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], 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"], }, protocols: { @@ -1807,6 +1810,7 @@ function ChatMarkdown({ onUseArtifactTemplate, imageBaseDir, onImageExpand, + extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -2203,6 +2207,16 @@ function ChatMarkdown({ : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); + const pullRequestAutolink = String( + (props as Record)["data-pull-request-autolink"] ?? "", + ); + const pullRequestCopy = + pullRequestAutolink === "commit" + ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" + ? plainHastText(node) + : undefined; + const isPullRequestAutolink = pullRequestCopy !== undefined; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); @@ -2210,6 +2224,8 @@ function ChatMarkdown({ const link = ( - {faviconHost && hastHasText(node) ? ( + {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( {linkChildren} @@ -2438,6 +2454,14 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + const remarkPlugins = useMemo( + () => [ + ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...extraRemarkPlugins, + ], + [extraRemarkPlugins, lineBreaks], + ); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. @@ -2450,9 +2474,7 @@ function ChatMarkdown({ onCopy={handleCopy} > ) : detail ? ( - <> + {mountedTabs.has("summary") ? (
) : null} - +
) : null}
diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index f782e5be113d..ad06eb21e2ae 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,10 +1,14 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import { createContext, useContext, useMemo } from "react"; +import type { Options as ReactMarkdownOptions } from "react-markdown"; import { cn } from "~/lib/utils"; import ChatMarkdown from "../ChatMarkdown"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +export const PullRequestMarkdownContext = createContext(null); /** * A pull request body, rendered with the app's markdown renderer plus a card for each upload @@ -27,6 +31,11 @@ export function PullRequestMarkdown({ className?: string; }) { const segments = splitPullRequestBody(text); + const repositoryUrl = useContext(PullRequestMarkdownContext); + const extraRemarkPlugins = useMemo>( + () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []), + [repositoryUrl], + ); return (
{segments.map((segment) => { @@ -37,6 +46,7 @@ export function PullRequestMarkdown({ text={segment.text} cwd={cwd} environmentId={environmentId} + extraRemarkPlugins={extraRemarkPlugins} /> ); } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index e322b595a182..79ae60e928ca 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,3 +1,9 @@ +import { + findAndReplaceText, + type MarkdownNode, + type TextMatch, +} from "~/vendor/mdast-find-and-replace"; + /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = | { readonly id: string; readonly kind: "markdown"; readonly text: string } @@ -141,3 +147,46 @@ export function splitPullRequestBody(body: string): ReadonlyArray { + findAndReplaceText( + tree, + AUTOLINK_CANDIDATE_PATTERN, + (matched: string, match: TextMatch) => { + const reference = matched.startsWith("#"); + const before = match.input[match.index - 1]; + const after = match.input[match.index + matched.length]; + if ( + (before !== undefined && + (reference + ? AUTOLINK_WORD_CHARACTER_PATTERN.test(before) + : !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) || + (after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after)) + ) { + return false; + } + return { + type: "link", + url: reference + ? `${repositoryUrl}/issues/${matched.slice(1)}` + : `${repositoryUrl}/commit/${matched}`, + data: { + hProperties: { + dataPullRequestAutolink: reference ? "reference" : "commit", + }, + }, + children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }], + }; + }, + AUTOLINK_IGNORED_TYPES, + ); + }; +} diff --git a/apps/web/src/vendor/mdast-find-and-replace.ts b/apps/web/src/vendor/mdast-find-and-replace.ts new file mode 100644 index 000000000000..2f16ab202ba0 --- /dev/null +++ b/apps/web/src/vendor/mdast-find-and-replace.ts @@ -0,0 +1,90 @@ +/* + * Adapted from mdast-util-find-and-replace: + * https://github.com/syntax-tree/mdast-util-find-and-replace/blob/main/lib/index.js + * + * The MIT License + * + * Copyright (c) Titus Wormer + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export type MarkdownNode = { + type: string; + value?: string; + children?: MarkdownNode[]; + url?: string; + data?: unknown; +}; + +export type TextMatch = { + index: number; + input: string; +}; + +/** The dependency-free subset of mdast-util-find-and-replace used by PR autolinks. */ +export function findAndReplaceText( + tree: MarkdownNode, + find: RegExp, + replace: (matched: string, match: TextMatch) => MarkdownNode | false, + ignoredTypes: ReadonlySet, +): void { + visit(tree); + + function visit(node: MarkdownNode): void { + if (node.children === undefined) return; + for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) { + const child = node.children[childIndex]!; + if (ignoredTypes.has(child.type)) continue; + if (child.type !== "text" || child.value === undefined) { + visit(child); + continue; + } + + const replacements: MarkdownNode[] = []; + let start = 0; + let changed = false; + find.lastIndex = 0; + let match = find.exec(child.value); + while (match !== null) { + const position = match.index; + const replacement = replace(match[0], { index: position, input: match.input }); + if (replacement === false) { + find.lastIndex = position + 1; + } else { + if (start < position) { + replacements.push({ type: "text", value: child.value.slice(start, position) }); + } + replacements.push(replacement); + start = position + match[0].length; + changed = true; + } + if (!find.global) break; + match = find.exec(child.value); + } + + if (!changed) continue; + if (start < child.value.length) { + replacements.push({ type: "text", value: child.value.slice(start) }); + } + node.children.splice(childIndex, 1, ...replacements); + childIndex += replacements.length - 1; + } + } +} From b17cc3d1bf0f4a5deee6dd6a470e36970b864a9b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 31 Aug 2026 20:36:53 -0700 Subject: [PATCH 26/42] perf(server): reduce frequency of full tool call output being loaded into memory from db (#8988) --- .../orchestration/Layers/CheckpointReactor.ts | 2 +- .../Layers/ProjectionPipeline.test.ts | 170 ++++++++++++------ .../Layers/ProjectionPipeline.ts | 4 +- .../Layers/ProjectionSnapshotQuery.test.ts | 71 ++++++++ .../Layers/ProjectionSnapshotQuery.ts | 111 +++++++++--- .../Layers/ProviderCommandReactor.ts | 2 +- .../Layers/ProviderRuntimeIngestion.ts | 10 +- .../Services/ProjectionSnapshotQuery.ts | 10 ++ .../Layers/ProjectionThreadActivities.ts | 72 ++++++-- .../Services/ProjectionThreadActivities.ts | 9 + 10 files changed, 363 insertions(+), 98 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..dd9d397200e7 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -164,7 +164,7 @@ const make = Effect.gen(function* () { const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 57215803f4b9..b8f65a54b92e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2235,7 +2235,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("clears stale pending user input from projected shell summaries", () => + it.effect("reads only user-input activities when refreshing shell summaries", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2293,70 +2293,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + // Invalid JSON proves the summary query filters tool rows before decoding payloads. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-malformed-tool-output', + 'thread-stale-user-input', + NULL, + 'info', + 'tool.completed', + 'Tool completed', + '{not-json', + NULL, + '2026-02-26T12:35:02.000Z' + ), + ( + 'activity-user-input-resolved-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:03.000Z' + ), + ( + 'activity-user-input-resolved', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.resolved', + 'User input resolved', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:04.000Z' + ), + ( + 'activity-user-input-stale-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-stale'), + NULL, + '2026-02-26T12:35:05.000Z' + ), + ( + 'activity-user-input-stale-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-stale', + 'detail', + 'Unknown pending Codex user input request: user-input-stale' + ), + NULL, + '2026-02-26T12:35:06.000Z' + ), + ( + 'activity-user-input-active-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-active'), + NULL, + '2026-02-26T12:35:07.000Z' + ), + ( + 'activity-user-input-active-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-active', + 'detail', + 'Provider is temporarily unavailable' + ), + NULL, + '2026-02-26T12:35:08.000Z' + ) + `; + yield* appendAndProject({ - type: "thread.activity-appended", + type: "thread.message-sent", eventId: EventId.make("evt-stale-user-input-3"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:02.000Z", + occurredAt: "2026-02-26T12:35:09.000Z", commandId: CommandId.make("cmd-stale-user-input-3"), causationEventId: null, correlationId: CorrelationId.make("cmd-stale-user-input-3"), metadata: {}, payload: { threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-requested"), - tone: "info", - kind: "user-input.requested", - summary: "User input requested", - payload: { - requestId: "user-input-request-stale-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - }, - ], - }, - turnId: null, - createdAt: "2026-02-26T12:35:02.000Z", - }, - }, - }); - - yield* appendAndProject({ - type: "thread.activity-appended", - eventId: EventId.make("evt-stale-user-input-4"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:03.000Z", - commandId: CommandId.make("cmd-stale-user-input-4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-user-input-4"), - metadata: {}, - payload: { - threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-failed"), - tone: "error", - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - payload: { - requestId: "user-input-request-stale-1", - detail: - "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1", - }, - turnId: null, - createdAt: "2026-02-26T12:35:03.000Z", - }, + messageId: MessageId.make("message-stale-user-input"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "2026-02-26T12:35:09.000Z", + updatedAt: "2026-02-26T12:35:09.000Z", }, }); @@ -2367,7 +2425,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { FROM projection_threads WHERE thread_id = 'thread-stale-user-input' `; - assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]); + assert.deepEqual(threadRows, [{ pendingUserInputCount: 1 }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ac514d7eb282..672336816727 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -130,7 +130,7 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } -// A full refresh loads all thread history, so skip events that cannot change the summary. +// A refresh reads each persisted summary source, so skip events that cannot change the result. function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { if (event.type === "thread.message-sent") { return event.payload.role === "user"; @@ -588,7 +588,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ projectionThreadMessageRepository.listByThreadId({ threadId }), projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), projectionPendingApprovalRepository.listByThreadId({ threadId }), ]); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 30892c760e77..0a375c762914 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -484,6 +484,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES + ( + 'activity-task-started', + 'thread-1', + 'turn-1', + 'info', + 'task.started', + 'Ship the query filter', + '{"taskId":"task-1","detail":"Ship the query filter"}', + '2026-02-24T00:00:06.100Z' + ), + ( + 'activity-malformed-tool', + 'thread-1', + 'turn-1', + 'info', + 'tool.completed', + 'Malformed tool output', + 'not-json', + '2026-02-24T00:00:06.200Z' + ) + `; + + const detailWithoutActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: [] }, + ); + assert.equal(detailWithoutActivities._tag, "Some"); + if (detailWithoutActivities._tag === "Some") { + assert.deepEqual(detailWithoutActivities.value.activities, []); + assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); + assert.deepEqual( + detailWithoutActivities.value.proposedPlans, + snapshot.threads[0]?.proposedPlans, + ); + assert.deepEqual( + detailWithoutActivities.value.checkpoints, + snapshot.threads[0]?.checkpoints, + ); + } + + const detailWithTaskActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: ["task.started", "task.progress"] }, + ); + assert.equal(detailWithTaskActivities._tag, "Some"); + if (detailWithTaskActivities._tag === "Some") { + assert.deepEqual(detailWithTaskActivities.value.activities, [ + { + id: asEventId("activity-task-started"), + tone: "info", + kind: "task.started", + summary: "Ship the query filter", + payload: { taskId: "task-1", detail: "Ship the query filter" }, + turnId: asTurnId("turn-1"), + createdAt: "2026-02-24T00:00:06.100Z", + }, + ]); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0b9698eaf9cf..d3270b0ce521 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -64,6 +64,7 @@ import { type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, + type ProjectionThreadDetailQuery, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -141,6 +142,10 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const ThreadActivityKindsLookupInput = Schema.Struct({ + threadId: ThreadId, + activityKinds: Schema.Array(Schema.String), +}); // Windowed reads order turns by the stable keyset (anchor, turn key), where // anchor is requested_at and turn key is // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the @@ -1055,6 +1060,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ + Request: ThreadActivityKindsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, activityKinds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ${sql.in("kind", activityKinds)} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const getThreadSessionRowByThread = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadSessionDbRowSchema, @@ -2524,8 +2571,43 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly beforeTurnKey: string; } - const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => + const getThreadDetailByIdBounded = ( + threadId: ThreadId, + bounds: ThreadDetailBounds | undefined, + query: ProjectionThreadDetailQuery | undefined = undefined, + ) => Effect.gen(function* () { + const activityRowsEffect = ( + query?.activityKinds === undefined + ? bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + : query.activityKinds.length === 0 + ? Effect.succeed([]) + : listThreadActivityRowsByThreadAndKinds({ + threadId, + activityKinds: query.activityKinds, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ); + const pinnedActivityRowsEffect = + query?.activityKinds === undefined + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ) + : Effect.succeed([]); + const [ threadRow, messageRows, @@ -2563,25 +2645,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - (bounds === undefined - ? listThreadActivityRowsByThread({ threadId }) - : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) - ).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", - ), - ), - ), - listPinnedThreadActivityRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", - ), - ), - ), + activityRowsEffect, + pinnedActivityRowsEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2700,8 +2765,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => - getThreadDetailByIdBounded(threadId, undefined); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( + threadId, + query, + ) => getThreadDetailByIdBounded(threadId, undefined, query); // Bounds pathological fan-out: one user turn that spawned hundreds of // subagent turns still pages in bounded chunks, at the cost of splitting the diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 22c70094ce0e..a37f0958dd55 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -481,7 +481,7 @@ const make = Effect.gen(function* () { const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7ec3a7e64243..198f163d8a89 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -48,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; +const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier @@ -949,9 +950,12 @@ const make = Effect.gen(function* () { ), ); - const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( + threadId: ThreadId, + activityKinds: ReadonlyArray = [], + ) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds }) .pipe(Effect.map(Option.getOrUndefined)); }); @@ -2022,7 +2026,7 @@ const make = Effect.gen(function* () { if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); if (!taskTitle) { - const threadDetail = yield* getLoadedThreadDetail(); + const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); } } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 0a00253a2285..f783d4d07bba 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -54,6 +54,15 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +export interface ProjectionThreadDetailQuery { + /** + * Limit activities before SQLite returns and decodes their payloads. + * Any explicit filter omits pinned-request reads. An empty list also skips + * the activity query. Omit this option to preserve the full detail response. + */ + readonly activityKinds?: ReadonlyArray; +} + /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape { */ readonly getThreadDetailById: ( threadId: ThreadId, + query?: ProjectionThreadDetailQuery, ) => Effect.Effect, ProjectionRepositoryError>; /** diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f96545..fa3c948e4f3d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); +const mapActivityRows = ( + rows: ReadonlyArray>, +): ReadonlyArray => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })); + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => Schema.isSchemaError(cause) @@ -97,6 +112,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listUserInputLifecycleActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -124,21 +169,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), - ), + Effect.map(mapActivityRows), ); + const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = + (input) => + listUserInputLifecycleActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:query", + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", + ), + ), + Effect.map(mapActivityRows), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -149,6 +194,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { return { upsert, listByThreadId, + listUserInputLifecycleByThreadId, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c479..e8c1e47a328b 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * List activity rows used to derive pending user-input state. + * + * Filters in SQLite so unrelated payloads do not enter server memory. + */ + readonly listUserInputLifecycleByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ From d35c71d1b975660ec6a71bbceb6aa7dfd0e8c3d5 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 00:16:57 -0400 Subject: [PATCH 27/42] feat(web): add pull request list filters (#8809) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../PullRequestListFilters.test.tsx | 7 +- .../pullRequest/PullRequestListFilters.tsx | 432 +++++++++++++----- .../components/pullRequest/PullRequestRow.tsx | 20 +- .../pullRequest/PullRequestSummaryTab.tsx | 9 +- .../pullRequest/pullRequestList.logic.ts | 74 ++- apps/web/src/routes/_chat.pull-requests.tsx | 194 +++++++- docs/user/source-control.md | 3 + 7 files changed, 585 insertions(+), 154 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx index f1c3013167f7..7ec2629c77b3 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx @@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode { if (!isValidElement(child)) continue; const props = child.props as { readonly children?: ReactNode; readonly label?: string }; if (props.label === label && typeof child.type === "function") { - return (child.type as (properties: unknown) => ReactNode)(child.props); + const rendered = (child.type as (properties: unknown) => ReactNode)(child.props); + return findLabeledGroup(rendered, label) ?? rendered; } const nested = findLabeledGroup(props.children, label); if (nested !== undefined) return nested; @@ -126,7 +127,7 @@ describe("pull request filters menu", () => { projectEnvironmentId: environmentId, onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId })); @@ -156,7 +157,7 @@ describe("pull request filters menu", () => { ], onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange( diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 67d2d77e4c94..9c3bfbab0c19 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -18,10 +18,11 @@ import { ListFilterIcon, LoaderIcon, SearchIcon, + TagIcon, + UserRoundIcon, } from "lucide-react"; -import type { ElementType } from "react"; +import { type ElementType, useState } from "react"; -import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; @@ -29,27 +30,56 @@ import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuGroupLabel, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + pullRequestLabelColor, + type PullRequestAuthorFacet, + type PullRequestLabelFacet, +} from "./pullRequestList.logic"; +import { PullRequestActorAvatar } from "./pullRequestPresentation"; export interface PullRequestFilterOption { readonly value: Value; readonly label: string; - /** - * Carries the option's own tone, so an icon reads the same here as it does on a row. Left - * uncoloured, which lets the item's selected state stay the thing the eye follows. - */ + /** Uses the option's native icon tone. */ readonly Icon: ElementType<{ className?: string }>; + readonly favicon?: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + }; /** Why it cannot be chosen, carried onto the item as its title. */ readonly unavailable?: string | undefined; } +export function PullRequestFilterOptionIcon({ + option, +}: { + option: PullRequestFilterOption; +}) { + return option.favicon ? ( + + ) : ( + + ); +} + export interface PullRequestExpectedHost { readonly host: string; readonly kind: SourceControlProviderKind; @@ -98,10 +128,8 @@ export function PullRequestSearchInput({ } /** - * Every list filter lives behind the one filter icon so the control row stays two controls - * wide: the search and this. The trigger carries a dot whenever any filter is off its - * default, so a narrowed list is never a mystery. Same menu chrome as the detail panel's - * actions, which also owns its own spacing. + * List narrowings live behind one filter control, separate from sorting. The trigger carries a + * count whenever any filter is off its default, so a narrowed list is never a mystery. */ const ALL_PROJECTS_VALUE = "all"; /** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */ @@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup({ disabled={option.unavailable !== undefined} > - - {option.label} + + {option.label} + {option.unavailable ? · Unavailable : null} ); @@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup({ ); } +function PullRequestFilterRadioSubmenu({ + label, + value, + options, + onChange, +}: { + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; + return ( + + + + {label} + + {current.label} + + + + + + + ); +} + +function PullRequestAuthorFilter({ + value, + options, + onChange, +}: { + value: string | undefined; + options: ReadonlyArray; + onChange: (author: string | undefined) => void; +}) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const login = value?.toLowerCase() ?? ""; + const selected = options.find((option) => option.actor.login.toLowerCase() === login); + const visible = [ + ...(selected ? [selected] : []), + ...options.filter( + (option) => + option !== selected && + (needle.length === 0 || + option.actor.login.toLowerCase().includes(needle) || + option.actor.name?.toLowerCase().includes(needle)), + ), + ].slice(0, 10); + const select = (next: string) => next.toLowerCase() !== login && onChange(next || undefined); + return ( + + + + Author + + {value ?? "Anyone"} + + + +
+ + + + + setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); + }} + placeholder="Search authors" + aria-label="Search authors" + /> + +
+ + + + + Anyone + + + {visible.map((option) => ( + + + + {option.actor.login} + + {option.mergedCount} merges loaded + + + + ))} + {visible.length === 0 ? No authors found : null} + +
+
+ ); +} + +function PullRequestLabelFilter({ + value, + options, + onChange, +}: { + value: ReadonlyArray; + options: ReadonlyArray; + onChange: (labels: ReadonlyArray) => void; +}) { + const selected = new Set(value.map((name) => name.toLowerCase())); + const visible = [ + ...value + .filter((name) => !options.some((option) => option.name.toLowerCase() === name.toLowerCase())) + .map((name) => ({ name, color: null, count: 0 })), + ...options, + ]; + return ( + + + + Labels + + {value.length === 0 ? "Any" : `${value.length} selected`} + + + + {visible.length === 0 ? ( + No labels in this view + ) : ( + visible.map((option) => { + const key = option.name.toLowerCase(); + const checked = selected.has(key); + const dot = pullRequestLabelColor(option.color); + return ( + + onChange( + next + ? [...value, option.name] + : value.filter((name) => name.toLowerCase() !== option.name.toLowerCase()), + ) + } + > + + + {option.name} + + {option.count} + + + + ); + }) + )} + + + ); +} + export function PullRequestFiltersMenu({ + onOpenChange, state, stateOptions, onState, @@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({ onInvolvement, filters, onFilters, + authorOptions = [], + labelOptions = [], host, hostOptions, onHost, @@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({ unavailable, onProject, }: { + onOpenChange?: (open: boolean) => void; state: PullRequestListState; stateOptions: ReadonlyArray>; onState: (state: PullRequestListState) => void; @@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({ /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */ filters: PullRequestListFilters; onFilters: (filters: PullRequestListFilters) => void; + authorOptions?: ReadonlyArray; + labelOptions?: ReadonlyArray; host: string | undefined; /** * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real @@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({ /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; }) { - const filtered = - state !== "open" || - involvement !== "all" || - host !== undefined || - server !== undefined || - projectId !== undefined || - Object.keys(filters).length > 0; - /** - * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in - * it as an explicit `undefined`, which the listing input does not accept. - */ - const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters => - Object.fromEntries( - Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter( - ([, held]) => held !== undefined, - ), - ) as PullRequestListFilters; + const selectedLabels = (filters.labels ?? []).flatMap((group) => group); + const filterCount = [ + state !== "open", + involvement !== "all", + host, + server, + projectId, + filters.draft, + filters.review, + filters.checks, + filters.author, + ...selectedLabels, + ].filter(Boolean).length; + const updateFilters = (next: Partial) => + onFilters( + Object.fromEntries( + Object.entries({ ...filters, ...next }).filter(([, value]) => value !== undefined), + ) as PullRequestListFilters, + ); + const updateFilter = (key: keyof PullRequestListFilters, value: string) => + updateFilters({ + [key]: value === UNFILTERED_VALUE ? undefined : value, + } as Partial); + const projectValue = + projectId === undefined || projectEnvironmentId === undefined + ? ALL_PROJECTS_VALUE + : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId }); + const projectOptions: ReadonlyArray> = [ + { value: ALL_PROJECTS_VALUE, label: "All projects", Icon: LayersIcon }, + ...projects + .toSorted( + (left, right) => + Number(unavailable.has(pullRequestProjectKey(left))) - + Number(unavailable.has(pullRequestProjectKey(right))), + ) + .map((project) => ({ + value: pullRequestProjectKey(project), + label: project.title, + Icon: FolderGit2Icon, + favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot }, + ...(unavailable.has(pullRequestProjectKey(project)) + ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } + : {}), + })), + ]; return ( -
+ 0 ? "[--control-icon-color:currentColor]" : undefined} variant="outline" - aria-label="Filter pull requests" /> } > - {filtered ? ( - + Filters + {filterCount > 0 ? ( + + {filterCount} + ) : null} - - + - - - updateFilters({ author })} + /> + + updateFilters({ + labels: labels.length === 0 ? undefined : labels.slice(0, 10).map((label) => [label]), + }) + } + /> + onFilters(withFilter("draft", next))} + onChange={(draft) => updateFilter("draft", draft)} /> - - onFilters(withFilter("review", next))} + onChange={(review) => updateFilter("review", review)} /> - - onFilters(withFilter("checks", next))} + onChange={(checks) => updateFilter("checks", checks)} /> {hostOptions.length > 2 ? ( <> - 2 ? ( <> - ) : null} - { - if (next === ALL_PROJECTS_VALUE) { - if (projectId !== undefined) onProject(undefined, undefined); - return; - } - // The value carries both halves, since the id alone cannot tell two servers' rows - // apart once they share one. + { const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next); - if ( - project !== undefined && - (project.id !== projectId || project.environmentId !== projectEnvironmentId) - ) { - onProject(project.id, project.environmentId); - } + if (project) onProject(project.id, project.environmentId); + else if (projectId !== undefined) onProject(undefined, undefined); }} - > - Project - - - - All projects - - - {/* The ones that can be chosen first: a list that opens with three disabled rows reads - as a broken menu rather than as a workspace with three unreadable repositories. */} - {projects - .toSorted( - (left, right) => - Number(unavailable.has(pullRequestProjectKey(left))) - - Number(unavailable.has(pullRequestProjectKey(right))), - ) - .map((project) => { - const reason = unavailable.get(pullRequestProjectKey(project)); - const item = ( - - - - {project.title} - {reason === undefined ? null : ( - - Unavailable - - )} - - - ); - if (reason === undefined) return item; - return ( - - - - {reason} - - - ); - })} - + /> ); diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index c7f731f9be13..b6b96ab7f55e 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -7,7 +7,7 @@ import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; -import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; +import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PullRequestActorLabel, @@ -16,6 +16,23 @@ import { PullRequestStateGlyph, } from "./pullRequestPresentation"; +function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) { + const label = labels[0]; + if (!label) return null; + const dot = pullRequestLabelColor(label.color); + return ( + + + {label.name} + {labels.length > 1 ? +{labels.length - 1} : null} + + ); +} + function PullRequestRowImpl({ entry, selected, @@ -118,6 +135,7 @@ function PullRequestRowImpl({ className="min-w-4 max-w-40" labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate" /> + {entry.labels.length > 0 ? : null} {/* Only a verdict somebody has actually given: "review required" is the absence of one, and saying so on every unreviewed row would say nothing. */} {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index ef3ae062f65a..7d31ec6e4bab 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -57,6 +57,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; +import { pullRequestLabelColor } from "./pullRequestList.logic"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; /** One reviewer, however a host happens to have cased their login this time. */ @@ -64,12 +65,6 @@ function reviewerKey(login: string): string { return login.toLowerCase(); } -/** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ -function labelDotColor(color: string | null): string | null { - const hex = color?.trim().replace(/^#/, "") ?? ""; - return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; -} - /** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */ function CommentAuthor({ actor }: { actor: PullRequestActor | null }) { const login = actor?.login ?? "ghost"; @@ -619,7 +614,7 @@ export function PullRequestSummaryTab({ } label="Labels"> {detail.labels.map((label) => { - const dot = labelDotColor(label.color); + const dot = pullRequestLabelColor(label.color); return ( ; } +export interface PullRequestAuthorFacet { + readonly actor: PullRequestActor; + readonly count: number; + readonly mergedCount: number; +} + +export interface PullRequestLabelFacet extends PullRequestLabel { + readonly count: number; +} + /** * The signed-in account per host. Keyed `" "` once a listing spans more than * one environment: two machines can both reach github.com signed in as different people, and a @@ -65,6 +77,59 @@ function normalize(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +export function pullRequestLabelColor(color: string | null): string | null { + const hex = color?.trim().replace(/^#/, "") ?? ""; + return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; +} + +export function collectPullRequestListFacets( + entries: ReadonlyArray, + state: PullRequestListState, +) { + const authors = new Map(); + const labels = new Map(); + const uniqueEntries = new Map(entries.map((entry) => [pullRequestEntryKey(entry), entry])); + for (const entry of uniqueEntries.values()) { + const inState = state === "all" || entry.state === state; + if (entry.author !== null) { + const key = normalize(entry.author.login); + if (key !== null) { + const held = authors.get(key); + authors.set(key, { + actor: held?.actor ?? entry.author, + count: (held?.count ?? 0) + Number(inState), + mergedCount: (held?.mergedCount ?? 0) + Number(entry.state === "merged"), + }); + } + } + if (!inState) continue; + for (const label of entry.labels) { + const key = normalize(label.name); + if (key === null) continue; + const held = labels.get(key); + labels.set(key, { + ...label, + name: held?.name ?? label.name, + color: held?.color ?? label.color, + count: (held?.count ?? 0) + 1, + }); + } + } + return { + authors: [...authors.values()] + .filter((author) => author.count > 0) + .toSorted( + (left, right) => + right.mergedCount - left.mergedCount || + right.count - left.count || + left.actor.login.localeCompare(right.actor.login), + ), + labels: [...labels.values()].toSorted( + (left, right) => right.count - left.count || left.name.localeCompare(right.name), + ), + }; +} + /** * The signed-in login for the host a row came from, or null where none was given. Shared by * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed. @@ -433,13 +498,16 @@ export function mergePullRequestDiffStats( if (stats.length === 0) return previous; const next = new Map(previous); for (const stat of stats) { - next.set(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions }); + next.set(pullRequestDiffStatKey(stat), { + additions: stat.additions, + deletions: stat.deletions, + }); } return next; } /** A project id only names a project within its own environment, so the key carries both. */ -const diffStatKey = (row: { +export const pullRequestDiffStatKey = (row: { readonly environmentId: string; readonly projectId: string; readonly number: number; @@ -792,6 +860,6 @@ export function withDiffStat< statsByRow: ReadonlyMap, ): Entry { if (entry.additions !== 0 || entry.deletions !== 0) return entry; - const stat = statsByRow.get(diffStatKey(entry)); + const stat = statsByRow.get(pullRequestDiffStatKey(entry)); return stat === undefined ? entry : { ...entry, ...stat }; } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 0f5d2edc9b6e..fcd17dbb7b83 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -13,7 +13,11 @@ import type { } from "@t3tools/contracts"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { + ArrowDownUpIcon, + CalendarArrowDownIcon, + CalendarArrowUpIcon, ChevronDownIcon, + ClockIcon, EyeIcon, MonitorIcon, ServerIcon, @@ -23,6 +27,8 @@ import { LayersIcon, PenLineIcon, LoaderIcon, + Maximize2Icon, + Minimize2Icon, RefreshCwIcon, SearchIcon, } from "lucide-react"; @@ -31,6 +37,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { filterPullRequestsByInvolvement, findScopedProject, + collectPullRequestListFacets, groupPullRequestsByInvolvement, matchesPullRequestFilters, matchesPullRequestQuery, @@ -38,6 +45,7 @@ import { narrowPullRequestsToFilters, mergePullRequestDiffStats, partitionPullRequestsWithPriority, + pullRequestDiffStatKey, pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, @@ -58,6 +66,7 @@ import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequ import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { PullRequestFiltersMenu, + PullRequestFilterOptionIcon, PullRequestSearchInput, pullRequestHostLabel, pullRequestProjectKey, @@ -83,6 +92,7 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; +import { toSortableTimestamp } from "../lib/threadSort"; import { selectActiveRightPanelSurface, selectSelectedRightPanelSurface, @@ -136,8 +146,13 @@ export interface PullRequestsSearch { readonly draft?: "only" | "hide"; readonly review?: NonNullable; readonly checks?: NonNullable; + readonly author?: string; + readonly labels?: ReadonlyArray; + readonly sort?: PullRequestListSort; } +type PullRequestListSort = "updated" | "newest" | "oldest" | "largest" | "smallest"; + // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. const INVOLVEMENT_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, @@ -152,6 +167,14 @@ const STATE_TABS = [ { value: "merged", label: "Merged", Icon: GitMergeIcon }, ] as const satisfies ReadonlyArray>; +const SORT_OPTIONS = [ + { value: "updated", label: "Recently updated", Icon: ClockIcon }, + { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, + { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, + { value: "largest", label: "Largest shown", Icon: Maximize2Icon }, + { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon }, +] as const satisfies ReadonlyArray>; + /** Long enough that a keystroke does not become a request, short enough to feel answered. */ const SEARCH_DEBOUNCE_MS = 250; /** What `scorePullRequestMatch` gives a row none of whose own fields carry the search text. */ @@ -181,6 +204,26 @@ const EMPTY_PREVIEW_SESSIONS = {}; const EMPTY_PREVIEW_DESKTOP_STATE = {}; const EMPTY_TERMINAL_LABELS = new Map(); const EMPTY_PENDING_SURFACES = new Set(); +const MAX_SEARCH_LABEL_CANDIDATES = 100; + +function pullRequestSearchLabels(raw: unknown): Partial> { + const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( + 0, + MAX_SEARCH_LABEL_CANDIDATES, + ); + const labels: Array = []; + const seen = new Set(); + for (const rawValue of values) { + if (typeof rawValue !== "string") continue; + const value = rawValue.trim().slice(0, 200); + const key = value.toLowerCase(); + if (value.length === 0 || seen.has(key)) continue; + labels.push(value); + seen.add(key); + if (labels.length === 10) break; + } + return labels.length === 0 ? {} : { labels }; +} export const Route = createFileRoute("/_chat/pull-requests")({ validateSearch: (raw: Record): PullRequestsSearch => ({ @@ -188,6 +231,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ raw.involvement === "reviewing" || raw.involvement === "authored" ? raw.involvement : "all", state: raw.state === "closed" || raw.state === "merged" || raw.state === "all" ? raw.state : "open", + ...(SORT_OPTIONS.some((option) => option.value === raw.sort) + ? { sort: raw.sort as PullRequestListSort } + : {}), ...(typeof raw.repository === "string" && raw.repository ? { repository: raw.repository.slice(0, 200) } : {}), @@ -216,12 +262,17 @@ export const Route = createFileRoute("/_chat/pull-requests")({ ? { review: raw.review } : {}), ...(raw.checks === "passing" || raw.checks === "failing" ? { checks: raw.checks } : {}), + ...(typeof raw.author === "string" && raw.author.trim() + ? { author: raw.author.trim().slice(0, 200) } + : {}), + ...pullRequestSearchLabels(raw.labels), }), component: PullRequestsRouteView, }); function PullRequestsRouteView() { const search = Route.useSearch(); + const sort = search.sort ?? "updated"; const navigate = useNavigate({ from: Route.fullPath }); const { environments } = useEnvironments(); // Every connected environment that has said it can list pull requests. Sorted, so the query @@ -416,6 +467,7 @@ function PullRequestsRouteView() { return { involvement: next.involvement ?? previous.involvement, state: next.state ?? previous.state, + ...(next.sort && next.sort !== "updated" ? { sort: next.sort } : {}), ...(next.repository ? { repository: next.repository } : {}), ...(next.number ? { number: next.number } : {}), ...(next.projectId ? { projectId: next.projectId } : {}), @@ -429,6 +481,8 @@ function PullRequestsRouteView() { ...(next.draft ? { draft: next.draft } : {}), ...(next.review ? { review: next.review } : {}), ...(next.checks ? { checks: next.checks } : {}), + ...(next.author ? { author: next.author } : {}), + ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}), }; }, replace: true, @@ -458,6 +512,7 @@ function PullRequestsRouteView() { // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is // late but the page is not. const typedQuery = (search.q ?? "").trim(); + const [filtersOpen, setFiltersOpen] = useState(false); const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS); const querySettled = typedQuery === sentQuery; // What was typed, split into the qualifiers the hosts can act on and the words that are left. @@ -472,12 +527,14 @@ function PullRequestsRouteView() { ...(search.draft ? { draft: search.draft } : {}), ...(search.review ? { review: search.review } : {}), ...(search.checks ? { checks: search.checks } : {}), + ...(search.author ? { author: search.author } : {}), + ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}), }), - [search.checks, search.draft, search.review], + [search.author, search.checks, search.draft, search.labels, search.review], ); const menuFiltered = Object.keys(menuFilters).length > 0; // A typed qualifier wins over the menu's own answer for the same thing, since it is the more - // recent word on it; labels only ever come from the query, so there is nothing to overrule. + // recent word on it, including a typed author or label over its menu counterpart. const filters = useMemo( (): PullRequestListFilters => ({ ...menuFilters, ...sentParsed.filters }), [menuFilters, sentParsed.filters], @@ -548,7 +605,7 @@ function PullRequestsRouteView() { [environmentQueries], ); // Page size is view state, not a URL concern: a shared link should open the first page. - const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}`; + const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`; const filterKey = `${scopeKey}:${sentQuery}`; // Where the next slice carries on from, per repository within each environment, as that // environment handed it back. Sending it is what makes a second page cost a second page rather @@ -660,6 +717,21 @@ function PullRequestsRouteView() { ], ); const baselineQuery = usePullRequestList(baselineTargets); + const facetTargets = useMemo(() => { + if (!filtersOpen) return NO_LIST_TARGETS; + return environmentQueries.map(({ environmentId, projectIds }) => ({ + environmentId, + input: { + state: "all", + involvement: search.involvement, + limit: PAGE_SIZE, + ...(scopedProjectId ? { projectId: scopedProjectId } : {}), + ...(projectIds ? { projectIds } : {}), + ...(search.host ? { host: search.host } : {}), + } satisfies PullRequestListInput, + })); + }, [environmentQueries, filtersOpen, scopedProjectId, search.host, search.involvement]); + const facetQuery = usePullRequestList(facetTargets); // The priority groups' own reads. The feed below is paginated by recency, so an older authored // or review-requested row can be missing from its first page; partitioned from these // server-filtered reads instead, the priority view is complete up front and a continuation can @@ -720,6 +792,7 @@ function PullRequestsRouteView() { } refreshList(); baselineQuery.refresh(); + facetQuery.refresh(); authoredQuery.refresh(); reviewingQuery.refresh(); statsQuery.refresh(); @@ -982,6 +1055,17 @@ function PullRequestsRouteView() { const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS; const listErrors = baselineQuery.data?.errors ?? listData?.errors ?? []; + const facets = useMemo( + () => + collectPullRequestListFacets( + [ + ...(facetQuery.data?.entries ?? []), + ...(baselineQuery.data?.entries ?? listData?.entries ?? []), + ], + search.state, + ), + [baselineQuery.data?.entries, facetQuery.data?.entries, listData?.entries, search.state], + ); /** The hosts that narrowed the listing themselves, so their answer is not narrowed again. */ const searchingHosts = useMemo( @@ -1170,6 +1254,40 @@ function PullRequestsRouteView() { if (stats === null) return; setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); }, [statsQuery.stats]); + const displayGroups = useMemo(() => { + const enriched = groups.map((group) => ({ + ...group, + entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), + })); + if (sort === "updated") return enriched; + const entries = enriched.flatMap((group) => group.entries); + const hasSize = (entry: (typeof entries)[number]) => + entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)); + const timestamp = (entry: (typeof entries)[number]) => + toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; + return [ + { + key: "others" as const, + label: "", + entries: entries.toSorted((left, right) => { + if (sort === "newest" || sort === "oldest") { + const leftCreated = toSortableTimestamp(left.createdAt); + const rightCreated = toSortableTimestamp(right.createdAt); + const measured = Number(rightCreated !== null) - Number(leftCreated !== null); + const dated = (leftCreated ?? 0) - (rightCreated ?? 0); + return ( + measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) + ); + } + const measured = Number(hasSize(right)) - Number(hasSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return ( + measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) + ); + }), + }, + ]; + }, [groups, sort, statsByRow]); const linkedSelection = useMemo( () => @@ -1347,6 +1465,7 @@ function PullRequestsRouteView() { onRefresh={() => void refreshFromHost()} query={typedQuery} filtered={ + menuFiltered || search.state !== "open" || search.involvement !== "all" || scopedProjectId !== undefined || @@ -1360,7 +1479,7 @@ function PullRequestsRouteView() { /> ) : (
- {groups.map((group) => ( + {displayGroups.map((group) => (
{group.label ? (

@@ -1370,10 +1489,7 @@ function PullRequestsRouteView() { {group.entries.map((entry) => ( 1 && @@ -1449,8 +1565,20 @@ function PullRequestsRouteView() { Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon, })), ]; + const sortMenu = ( + } + triggerLabel="Sort" + outlined + value={sort} + options={SORT_OPTIONS} + onChange={(next) => updateSearch({ sort: next })} + /> + ); const filtersMenu = ( updateListScope({ state })} @@ -1459,8 +1587,16 @@ function PullRequestsRouteView() { onInvolvement={(involvement) => updateListScope({ involvement })} filters={menuFilters} onFilters={(next) => - updateListScope({ draft: next.draft, review: next.review, checks: next.checks }) + updateListScope({ + draft: next.draft, + review: next.review, + checks: next.checks, + author: next.author, + labels: next.labels?.flatMap((group) => group), + }) } + authorOptions={facets.authors} + labelOptions={facets.labels} host={search.host} hostOptions={hostMenuOptions} onHost={(host) => updateListScope({ host })} @@ -1493,6 +1629,7 @@ function PullRequestsRouteView() { onState: (state: PullRequestListState) => updateListScope({ state }), onHost: (host: string | undefined) => updateListScope({ host }), searchInput, + sortMenu, filtersMenu, rightPanelControl: // Footprint reserve while the panel is closed: the toggle itself stays @@ -1625,12 +1762,18 @@ function PullRequestsRouteView() { /** A compact stand-in for one pill group when the header is narrow. */ function CompactFilterMenu({ label, + triggerIcon, + triggerLabel, + outlined = false, value, options, onChange, className, }: { label: string; + triggerIcon?: ReactNode; + triggerLabel?: string; + outlined?: boolean; value: Value; options: ReadonlyArray>; onChange: (value: Value) => void; @@ -1641,14 +1784,28 @@ function CompactFilterMenu({ return ( : undefined} + className={ + outlined + ? className + : cn( + "inline-flex h-7 min-w-0 items-center gap-1 rounded-md px-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground", + className, + ) + } > - {current.label} - + {triggerLabel ? ( + <> + {triggerIcon} + {triggerLabel} + + ) : ( + <> + {current.label} + + + )} onChange(next as Value)}> @@ -1661,7 +1818,7 @@ function CompactFilterMenu({ className="data-disabled:pointer-events-auto" > - + {option.label} @@ -1769,6 +1926,7 @@ function PullRequestsColumn({ onState, onHost, searchInput, + sortMenu, filtersMenu, rightPanelControl, titlebarControls, @@ -1786,6 +1944,7 @@ function PullRequestsColumn({ onState: (state: PullRequestListState) => void; onHost: (host: string | undefined) => void; searchInput: ReactNode; + sortMenu: ReactNode; filtersMenu: ReactNode; rightPanelControl: ReactNode; titlebarControls: ReactNode; @@ -1934,6 +2093,7 @@ function PullRequestsColumn({
{searchInput} + {sortMenu} {filtersMenu} {!condensed ? ( diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d848226c1baa..ac11e3a06f46 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,6 +41,9 @@ T3 Code works with the platforms your team already uses: - See if your current branch already has an open PR/MR - Open several reviews from the **Pull requests** page as tabs in the right panel +- Filter the list by author or labels, rank authors by merges in the loaded results, see label and + change-size context on each row, and sort the results currently shown by update time, creation + time, or change size - While working in a thread, open linked reviews in the same compact right-panel tabs without leaving the conversation - Open the review directly in your browser with one click From ff93aba61dc1f7839713580bc8ee08da2a395f9a Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 00:56:22 -0400 Subject: [PATCH 28/42] feat(web): search individual settings by detail (#8831) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../components/CommandPalette.logic.test.ts | 70 +++++ .../src/components/CommandPalette.logic.ts | 39 ++- apps/web/src/components/CommandPalette.tsx | 40 ++- .../settings/ConnectionsSettings.tsx | 14 +- ...ProviderSettingsPanel.environment.test.tsx | 38 +++ .../ProviderSettingsPanel.logic.test.ts | 22 ++ .../settings/ProviderSettingsPanel.logic.ts | 7 + .../settings/ProviderSettingsPanel.tsx | 52 +++- .../components/settings/SettingsPanels.tsx | 9 +- .../settings/SettingsSidebarNav.tsx | 19 +- .../settings/SourceControlSettings.tsx | 18 +- .../settings/SourceControlWritingSettings.tsx | 7 +- .../src/components/settings/ThemeSettings.tsx | 7 +- .../components/settings/settingsLayout.tsx | 12 + .../settings/settingsSearch.test.ts | 82 +++++- .../src/components/settings/settingsSearch.ts | 248 +++++++++++++++--- .../useAvailableSettingsSearchItems.ts | 44 ++++ apps/web/src/lib/utils.ts | 4 + docs/user/keybindings.md | 9 +- 19 files changed, 645 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/components/settings/useAvailableSettingsSearchItems.ts diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index d521c119303d..65f183940018 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -8,6 +8,7 @@ import { enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + normalizeSearchText, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -272,6 +273,75 @@ describe("buildThreadActionItems", () => { expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); + it("ranks an order-independent setting title match above a split context match", () => { + const settingsSearchItems = [ + { + kind: "action" as const, + value: "setting:context-match", + searchTerms: ["Pairing settings", "remote backend"], + title: "Context match", + icon: null, + run: async () => undefined, + }, + { + kind: "action" as const, + value: "setting:remote-pairing", + searchTerms: ["Remote pairing", "connections"], + title: "Remote pairing", + icon: null, + run: async () => undefined, + }, + ]; + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "pairing remote", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems, + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("settings-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "setting:remote-pairing", + "setting:context-match", + ]); + }); + + it("keeps accent-insensitive setting results", () => { + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "thè\u{1ab0}mes", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [ + { + kind: "action", + value: "setting:theme", + searchTerms: ["Themes", "Appearance"], + title: "Themes", + icon: null, + run: async () => undefined, + }, + ], + threadSearchItems: [], + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual(["setting:theme"]); + }); + + it("normalizes case independently of the host locale", () => { + const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); + try { + expect(normalizeSearchText("GIT")).toBe("git"); + expect(localeLowerCase).not.toHaveBeenCalled(); + } finally { + localeLowerCase.mockRestore(); + } + }); + it("keeps message excerpts searchable without replacing thread metadata", () => { const [item] = buildThreadActionItems({ threads: [makeThread({ branch: "feat/search" })], diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 688a8a8ea791..a0af1be0450b 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -9,9 +9,12 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; +import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +export { normalizeSearchText } from "../lib/utils"; + export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; @@ -138,10 +141,6 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; -export function normalizeSearchText(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, " "); -} - export function buildProjectActionItems(input: { projects: ReadonlyArray; valuePrefix: string; @@ -255,9 +254,16 @@ export function buildThreadActionItems, +): number { const normalizedField = normalizeSearchText(field); - if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { + if ( + normalizedField.length === 0 || + !queryTokens.every((token) => normalizedField.includes(token)) + ) { return Number.NEGATIVE_INFINITY; } if (normalizedField === normalizedQuery) { @@ -266,12 +272,16 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number { if (normalizedField.startsWith(normalizedQuery)) { return 2; } - return 1; + if (normalizedField.includes(normalizedQuery)) { + return 1; + } + return 0; } function rankCommandPaletteItemMatch( item: CommandPaletteActionItem | CommandPaletteSubmenuItem, normalizedQuery: string, + queryTokens: ReadonlyArray, ): number { const terms = item.searchTerms.filter((term) => term.length > 0); if (terms.length === 0) { @@ -279,7 +289,7 @@ function rankCommandPaletteItemMatch( } for (const [index, field] of terms.entries()) { - const fieldRank = rankSearchFieldMatch(field, normalizedQuery); + const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens); if (fieldRank !== Number.NEGATIVE_INFINITY) { return 1_000 - index * 100 + fieldRank; } @@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: { query: string; isInSubmenu: boolean; projectSearchItems: ReadonlyArray; + settingsSearchItems?: ReadonlyArray; threadSearchItems: ReadonlyArray; }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); @@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: { } return [...input.activeGroups]; } + const queryTokens = normalizedQuery.split(" "); let baseGroups = [...input.activeGroups]; if (isActionsFilter) { @@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: { items: input.projectSearchItems, }); } + if (input.settingsSearchItems && input.settingsSearchItems.length > 0) { + searchableGroups.push({ + value: "settings-search", + label: "Settings", + items: input.settingsSearchItems, + }); + } if (input.threadSearchItems.length > 0) { searchableGroups.push({ value: "threads-search", @@ -334,14 +353,14 @@ export function filterCommandPaletteGroups(input: { return searchableGroups.flatMap((group) => { const items = Arr.filterMap(group.items, (item, index) => { const haystack = normalizeSearchText(item.searchTerms.join(" ")); - if (!haystack.includes(normalizedQuery)) { + if (!queryTokens.every((token) => haystack.includes(token))) { return Result.failVoid; } return Result.succeed({ item, index, - rank: rankCommandPaletteItemMatch(item, normalizedQuery), + rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens), }); }) .toSorted((left, right) => right.rank - left.rank || left.index - right.index) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..52a36cf2ad56 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -32,7 +32,7 @@ import { type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, @@ -104,6 +104,7 @@ import { } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, parseWslUncPath, @@ -140,6 +141,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { COMMAND_PALETTE_META_ICON_CLASS, CommandPaletteMetaDot, @@ -563,6 +565,7 @@ function OpenCommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); @@ -585,6 +588,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -1637,7 +1641,19 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:project-settings", - searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + searchTerms: [ + "project", + "settings", + "name", + "icon", + "scripts", + "model", + "workspace", + "grouping", + "checkout", + "remove", + "t3.json", + ], title: "Project settings", description: contextualProjectGroup.displayName, icon: , @@ -1651,6 +1667,25 @@ function OpenCommandPaletteDialog(props: { } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const settingsSearchItems: CommandPaletteActionItem[] = searchSettings( + deferredQuery, + availableSettingsSearchItems, + ).map((item) => ({ + kind: "action", + value: `setting:${item.id}`, + searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])], + title: item.title, + description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`, + icon: , + run: async () => { + await navigate({ + to: item.to, + hash: item.targetId ?? item.id, + replace: pathname === item.to, + hashScrollIntoView: false, + }); + }, + })); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; const activeGroups = @@ -1668,6 +1703,7 @@ function OpenCommandPaletteDialog(props: { query: deferredQuery, isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, + settingsSearchItems, threadSearchItems: allThreadItems, }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6a65856b9b98..6ca31aa626f6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1670,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b <> {window.desktopBridge ? ( ) : null} ( ( ( {canManageLocalBackend ? ( <> - + {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( ) : ( - + ({ updateSettings: vi.fn(), })); +const settingsSearchState = vi.hoisted(() => ({ + targetId: null as string | null, + effects: [] as Array<() => void>, +})); + vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void) => settingsSearchState.effects.push(effect), useMemo: reactHookHarness.useMemo, useRef: reactHookHarness.useRef, useState: reactHookHarness.useState, }; }); +vi.mock("./settingsLayout", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSettingsSearchTargetId: () => settingsSearchState.targetId, + }; +}); + vi.mock("react/compiler-runtime", async () => { const { reactHookHarness } = await import("../../test/reactHookHarness"); return { c: reactHookHarness.useMemoCache }; @@ -131,6 +145,17 @@ function isAddProviderButton(element: ReactElement>): bo return element.props["aria-label"] === "Add provider"; } +function findAdvancedPanel(panel: ReactElement>) { + return visitElements( + panel, + (element) => element.props.className === "mt-1" && typeof element.props.open === "boolean", + ); +} + +function flushEffects(): void { + for (const effect of settingsSearchState.effects.splice(0)) effect(); +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -144,6 +169,8 @@ describe("EnvironmentProviderSettings routing", () => { settingsState.readEnvironmentIds = []; settingsState.updateEnvironmentIds = []; settingsState.updateSettings.mockReset(); + settingsSearchState.targetId = null; + settingsSearchState.effects = []; commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); }); @@ -235,6 +262,17 @@ describe("EnvironmentProviderSettings routing", () => { expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); }); + it("opens Advanced when search targets the provider health interval", () => { + settingsSearchState.targetId = "provider-health-check-interval"; + let panel = renderPanel(); + + expect(findAdvancedPanel(panel)?.props.open).toBe(false); + flushEffects(); + + panel = renderPanel(); + expect(findAdvancedPanel(panel)?.props.open).toBe(true); + }); + it("deletes and resets provider configuration without erasing shared preferences", () => { settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index bf558f5a4d66..c04db646a47e 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, resolveRemoteOperateAccess, resolveSelectedProviderEnvironmentId, @@ -20,6 +21,27 @@ const environments = [ ] as const; describe("provider environment selection", () => { + it("requires a connected environment with server config for searchable provider settings", () => { + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: true, + }), + ).toBe(true); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "reconnecting", + hasServerConfig: true, + }), + ).toBe(false); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: false, + }), + ).toBe(false); + }); + it("sorts the primary environment first and the rest by label", () => { expect( buildProviderEnvironmentOptions(environments, primaryId).map( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 1c7dac391f6a..b415b5f69b07 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike { readonly label: string; } +export function isProviderSettingsEnvironmentAvailable(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; +}): boolean { + return input.connectionPhase === "connected" && input.hasServerConfig; +} + export function buildProviderEnvironmentOptions( environments: ReadonlyArray, primaryEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 3fb193fe1072..bafff6f48b8b 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -33,7 +33,7 @@ import { RefreshCwIcon, TerminalIcon, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; @@ -93,10 +93,12 @@ import { SettingsRow, SettingsSection, useRelativeTimeTick, + useSettingsSearchTargetId, } from "./settingsLayout"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, type ProviderOperateAccess, resolvePrimaryOperateAccess, @@ -189,7 +191,7 @@ function EnvironmentUnavailableRow({ // No spinner: this state can persist indefinitely for a wedged device, and a // continuously repainting animation would run the whole time. return ( - + {deviceTabs} @@ -197,8 +199,17 @@ function EnvironmentUnavailableRow({ } export function ProviderSettingsPanel() { + return ( + + + + ); +} + +function ProviderSettingsPanelContent() { const { environments, isReady } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const searchTargetId = useSettingsSearchTargetId(); const options = useMemo( () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), [environments, primaryEnvironmentId], @@ -216,6 +227,27 @@ export function ProviderSettingsPanel() { ); const selectedEnvironment = options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const selectedEnvironmentCanRenderSettings = + selectedEnvironment !== null && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: selectedEnvironment.connection.phase, + hasServerConfig: selectedEnvironment.serverConfig !== null, + }); + const searchableEnvironmentId = options.find((environment) => + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + }), + )?.environmentId; + useEffect(() => { + if ( + searchTargetId === searchableSetting("provider-health-check-interval").id && + !selectedEnvironmentCanRenderSettings && + searchableEnvironmentId !== undefined + ) { + setSelectedEnvironmentId(searchableEnvironmentId); + } + }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]); const onlyPrimaryDevice = options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; const deviceTabs = @@ -266,9 +298,9 @@ export function ProviderSettingsPanel() { ) : null; return ( - + <> {options.length === 0 ? ( - + ) : null} - + ); } @@ -429,12 +461,19 @@ export function EnvironmentProviderSettings({ const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); const [selectedInstanceId, setSelectedInstanceId] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet >(() => new Set()); const refreshingRef = useRef(false); const updatingDriversRef = useRef>(new Set()); + useEffect(() => { + if (searchTargetId === searchableSetting("provider-health-check-interval").id) { + setAdvancedOpen(true); + } + }, [searchTargetId]); + const providerUpdateCandidates = useMemo( () => collectProviderUpdateCandidates(serverProviders), [serverProviders], @@ -915,9 +954,10 @@ export function EnvironmentProviderSettings({ className={readOnly ? "opacity-50 select-none" : undefined} > - Health check interval + {searchableSetting("provider-health-check-interval").title} This interval is configured here, then the shared Background activity policy decides whether provider probes may run when the timer fires. Custom diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index dbb2d96daa0f..b150533b0044 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -790,7 +790,9 @@ function BackgroundActivityAdvancedDialog({
-
Git fetch interval
+
+ {searchableSetting("git-fetch-interval").title} +

Refresh remote branch status in the background.

@@ -2009,7 +2011,7 @@ export function GeneralSettingsPanel() { /> {settings.sidebarAutoSettleAfterDays !== null ? ( - Background activity + {searchableSetting("background-activity").title} This shared policy gates background work such as Git refreshes and provider health probes after their individual intervals elapse. diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index d336bde5ee16..daf1724a38d9 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -7,8 +7,6 @@ import { type ComponentType, type KeyboardEvent, } from "react"; -import { useEnvironmentQuery } from "~/state/query"; -import { desktopWslStateAtom } from "~/state/desktopWslState"; import { ArchiveIcon, BlocksIcon, @@ -23,8 +21,6 @@ import { } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { isElectron } from "~/env"; -import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd } from "../ui/kbd"; @@ -42,11 +38,11 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, - SETTINGS_SEARCH_ITEMS, SETTINGS_SECTION_LABELS, type SettingsPath, type SettingsSearchItem, } from "./settingsSearch"; +import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; const SETTINGS_SECTION_ICONS: Readonly< Record> @@ -83,18 +79,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); - const searchableItems = useMemo(() => { - const wslState = desktopWsl.data; - const rowRenders = isWslSettingsRowVisible({ - state: wslState, - error: desktopWsl.error, - }); - if (rowRenders) { - return SETTINGS_SEARCH_ITEMS; - } - return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend"); - }, [desktopWsl.data, desktopWsl.error]); + const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index a43c116467ed..559a90f4bf5a 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,7 +1,7 @@ import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import type { BackgroundActivitySettings, SourceControlProviderKind, @@ -59,7 +59,9 @@ import { PolicyTooltip, SettingResetButton, SettingsPageContainer, + SettingsSearchTarget, SettingsSection, + useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -267,6 +269,13 @@ function DiscoveryItemRow({ const authAccount = auth ? optionLabel(auth.account) : null; const [isExpanded, setIsExpanded] = useState(false); const hasDetails = children !== undefined; + const searchTargetId = useSettingsSearchTargetId(); + + useEffect(() => { + if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + setIsExpanded(true); + } + }, [item.kind, searchTargetId]); return (
+
- Fetch interval + {setting.title} This interval is configured for Git only. The shared Background activity policy still decides whether Git refreshes may run when the timer fires. Custom intervals appear as @@ -407,7 +417,7 @@ function GitFetchIntervalSettings() { seconds
-
+
); } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index d7c094af372b..7f3660c52b21 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -21,6 +21,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; const MODE_OPTIONS: Record = { @@ -73,7 +74,7 @@ export function SourceControlWritingSettingsSection() { return ( diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 8f5ee2bba00d..b75d5f40f439 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -41,6 +41,7 @@ import { Button } from "../ui/button"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { ThemeImportDialog } from "./ThemeImportDialog"; +import { searchableSetting } from "./settingsSearch"; import { useThemeEditorStore } from "./themeEditorStore"; import { STANDARD_THEME_CARDS, @@ -892,11 +893,13 @@ export function ThemeLibrary({ Choose how T3 Code looks. Use a built-in theme or make your own.

- Color scheme + {searchableSetting("color-scheme").title}

{renderModeTiles()}
-

Themes

+

+ {searchableSetting("theme").title} +

) : null} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts new file mode 100644 index 000000000000..0289496c19ba --- /dev/null +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadId } from "@t3tools/contracts"; + +import { resolveViewedImageAsset, workEntryViewedImagePath } from "./presentation.js"; + +describe("workEntryViewedImagePath", () => { + const entry = { label: "Read", tone: "tool" } as const; + + it("returns a single image path from supported read entries", () => { + expect( + workEntryViewedImagePath({ ...entry, requestKind: "file-read", detail: " assets/a.png " }), + ).toBe("assets/a.png"); + expect( + workEntryViewedImagePath({ + ...entry, + itemType: "dynamic_tool_call", + toolTitle: "Read file", + detail: "C:\\workspace\\a.webp", + }), + ).toBe("C:\\workspace\\a.webp"); + }); + + it("rejects non-image, multi-line, and non-read details", () => { + expect( + workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.txt" }), + ).toBeNull(); + expect( + workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.png\nb.png" }), + ).toBeNull(); + expect(workEntryViewedImagePath({ ...entry, detail: "a.png" })).toBeNull(); + }); +}); + +describe("resolveViewedImageAsset", () => { + const threadId = ThreadId.make("thread-1"); + + it("loads t3 attachment paths as attachments", () => { + const attachmentId = + "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; + expect( + resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { + threadId, + workspaceRoot: "/workspace", + }), + ).toEqual({ + resource: { _tag: "attachment", attachmentId }, + alt: `${attachmentId}.png`, + srcFragment: "", + }); + }); + + it("normalizes workspace image sources", () => { + expect( + resolveViewedImageAsset("screens/logo.svg?v=2#mark", { + threadId, + workspaceRoot: "/workspace", + }), + ).toEqual({ + resource: { + _tag: "workspace-file", + threadId, + path: "/workspace/screens/logo.svg", + }, + alt: "logo.svg", + srcFragment: "#mark", + }); + expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 4834c037bb4c..416395916e1a 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -1,4 +1,12 @@ -import { isToolLifecycleItemType, type ToolLifecycleItemType } from "@t3tools/contracts"; +import { + isToolLifecycleItemType, + type AssetResource, + type ThreadId, + type ToolLifecycleItemType, +} from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; + +import { classifyMarkdownImageSource, markdownImageSourceFragment } from "../markdownImages.js"; export function isWorktreeSetupActivity(kind: string): boolean { return kind === "setup-script.requested" || kind === "setup-script.started"; @@ -9,6 +17,7 @@ export interface WorkLogPresentationEntry { readonly toolTitle?: string; readonly tone: "thinking" | "tool" | "info" | "error"; readonly command?: string; + readonly detail?: string; readonly changedFiles?: ReadonlyArray; readonly itemType?: ToolLifecycleItemType; readonly requestKind?: string; @@ -57,7 +66,8 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || - (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") + (entry.itemType === "dynamic_tool_call" && + entry.toolTitle?.trim().toLowerCase() === "read file") ) { return "read"; } @@ -76,6 +86,53 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio return workLogEntryIsToolLike(entry) ? "other" : "update"; } +export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null { + const detail = entry.detail?.trim(); + return toolGroupAction(entry) === "read" && + detail !== undefined && + !/[\r\n]/.test(detail) && + isWorkspaceImagePreviewPath(detail) + ? detail + : null; +} + +export interface ViewedImageAsset { + readonly resource: Extract; + readonly alt: string; + readonly srcFragment: string; +} + +const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i; +const T3_ATTACHMENT_IMAGE_PATH_PATTERN = + /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i; + +export function resolveViewedImageAsset( + source: string, + input: { + readonly threadId: ThreadId; + readonly workspaceRoot?: string | null | undefined; + }, +): ViewedImageAsset | null { + const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); + if (imageSource._tag !== "WorkspaceFile") return null; + + const path = + input.workspaceRoot == null && imageSource.path.startsWith("./") + ? imageSource.path.slice(2) + : imageSource.path; + const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source) + ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null) + : null; + + return { + resource: attachmentId + ? { _tag: "attachment", attachmentId } + : { _tag: "workspace-file", threadId: input.threadId, path }, + alt: path.split(/[\\/]/).at(-1) ?? "image", + srcFragment: markdownImageSourceFragment(source), + }; +} + function toolGroupActionCount( action: ToolGroupAction, entries: ReadonlyArray, From 0947c30e6946b2ad6d6cd518fd44292e75e834e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 31 Aug 2026 23:57:03 -0700 Subject: [PATCH 30/42] fix(client): use package import for markdown image helpers (#9010) --- packages/client-runtime/src/work-log/presentation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 416395916e1a..902745fdf958 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -4,10 +4,12 @@ import { type ThreadId, type ToolLifecycleItemType, } from "@t3tools/contracts"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; -import { classifyMarkdownImageSource, markdownImageSourceFragment } from "../markdownImages.js"; - export function isWorktreeSetupActivity(kind: string): boolean { return kind === "setup-script.requested" || kind === "setup-script.started"; } From 73776d4e52087331245e82ebf3c053ae0991935d Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:44:20 +0000 Subject: [PATCH 31/42] test: remove static presentation snapshots (#9008) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- apps/mobile/src/lib/typography.test.ts | 20 -------------------- apps/web/src/components/ui/menu.test.tsx | 23 ----------------------- 2 files changed, 43 deletions(-) delete mode 100644 apps/mobile/src/lib/typography.test.ts delete mode 100644 apps/web/src/components/ui/menu.test.tsx diff --git a/apps/mobile/src/lib/typography.test.ts b/apps/mobile/src/lib/typography.test.ts deleted file mode 100644 index 5b62e9bd3127..000000000000 --- a/apps/mobile/src/lib/typography.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; - -describe("mobile typography", () => { - it("uses the intentional mobile font scale anchored at a 16pt body", () => { - expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ - 11, 12, 13, 14, 16, 18, 21, 26, 30, - ]); - expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 }); - }); - - it("uses caption-sized code with a compact readable row height", () => { - expect(MOBILE_CODE_SURFACE).toMatchObject({ - fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, - lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, - rowHeight: 22, - }); - }); -}); diff --git a/apps/web/src/components/ui/menu.test.tsx b/apps/web/src/components/ui/menu.test.tsx deleted file mode 100644 index 079d2a1794b8..000000000000 --- a/apps/web/src/components/ui/menu.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { Menu, MenuRadioGroup, MenuRadioItem } from "./menu"; - -describe("menu radio item geometry", () => { - it("keeps radio-item icons on the same text grid as menu items", () => { - const html = renderToStaticMarkup( - - - - - - Merge - - - - , - ); - - expect(html).toContain("-mx-0.5"); - }); -}); From a9ffb8279614df6ae2f1f4b7f09a0dc42edef797 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 01:25:24 -0700 Subject: [PATCH 32/42] perf(server): bound snapshot activity payload memory (#9000) --- .../ActivityPayloadProjection.test.ts | 22 ++ .../ActivityPayloadProjection.ts | 31 +- .../Layers/ProjectionSnapshotQuery.test.ts | 157 ++++++++- .../Layers/ProjectionSnapshotQuery.ts | 329 ++++++++++++++---- .../Services/ProjectionSnapshotQuery.ts | 4 + 5 files changed, 449 insertions(+), 94 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 2cdfef19fd18..18732fa4ea37 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -64,6 +64,28 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(projected.payload).length).toBeLessThan(500); }); + it("keeps preview normalization and fence-only fallback while scanning lines", () => { + const preview = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: `\`\`\`\n actual\tresult \n${"x".repeat(5000)}` }, + }), + ); + const fences = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: "```\r\n \t \n```\n" }, + }), + ); + + expect((preview.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "actual result", + }); + expect((fences.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "2 lines", + }); + }); + it("keeps bounded Claude and ACP command output summaries", () => { const claude = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 32f249c251d5..7d2ba79ce4ad 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -144,22 +144,29 @@ function projectCommandValue(data: Record): unknown { } function summarizeToolTextOutput(value: string): string | null { - const lines: string[] = []; - for (const rawLine of value.split(/\r?\n/u)) { - const line = rawLine.replace(/\s+/g, " ").trim(); + let meaningfulLineCount = 0; + let offset = 0; + + while (offset <= value.length) { + const newlineIndex = value.indexOf("\n", offset); + const lineEnd = newlineIndex === -1 ? value.length : newlineIndex; + const line = value.slice(offset, lineEnd).replace(/\s+/g, " ").trim(); if (line.length > 0) { - lines.push(line); + meaningfulLineCount += 1; + if (line !== "```") { + const summary = line.length <= 84 ? line : `${line.slice(0, 83).trimEnd()}…`; + // V8 can retain the full tool output behind a short sliced string. + // Join a tiny character array so the returned preview owns its bytes. + return Array.from(summary).join(""); + } + } + if (newlineIndex === -1) { + break; } + offset = newlineIndex + 1; } - const firstLine = lines.find((line) => line !== "```"); - if (firstLine) { - return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; - } - if (lines.length > 1) { - return `${lines.length.toLocaleString()} lines`; - } - return null; + return meaningfulLineCount > 1 ? `${meaningfulLineCount.toLocaleString()} lines` : null; } /** diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 0a375c762914..fd8b601b13ea 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -21,6 +21,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; +import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -2389,9 +2390,84 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'thread-w', 'turn-5', 'tool', - 'tool.completed', + CASE + WHEN sequence = 2 THEN 'tool.updated' + WHEN sequence IN (3, 70) THEN 'context-window.updated' + ELSE 'tool.completed' + END, 'ran tool', - printf('{"sequence":%d}', sequence), + CASE + WHEN sequence IN (2, 80) THEN json_object( + 'itemType', 'command_execution', + 'toolCallId', 'cross-batch-call', + 'title', CASE WHEN sequence = 80 THEN 'Build completed' ELSE 'Build' END, + 'status', 'completed', + 'data', json_object( + 'toolCallId', 'cross-batch-call', + 'item', json_object( + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'command output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'x') + ) + ), + 'rawOutput', printf( + 'raw output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'y') + ), + 'files', json_array(json_object('path', 'apps/server/src/snapshot.ts')) + ) + ) + WHEN sequence = 10 THEN json_object( + 'itemType', 'mcp_tool_call', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'type', 'mcpToolCall', + 'id', 'mcp-item-10', + 'tool', 'fetch_pr', + 'server', 'github', + 'status', 'completed', + 'arguments', json_object('pr', 42), + 'result', json_object( + 'content', json_array(json_object( + 'type', 'text', + 'text', printf( + 'PR body line one%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'z') + ) + )) + ), + '_meta', json_object('raw', replace(hex(zeroblob(8192)), '00', 'q')) + ) + ) + ) + WHEN sequence = 11 THEN json_object( + 'itemType', 'command_execution', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'status', 'failed', + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'failed command%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'w') + ) + ), + 'rawOutput', json_object('stdout', 'failed output'), + 'files', json_array(json_object('path', 'apps/server/src/failed.ts')) + ) + ) + WHEN sequence IN (3, 70) THEN json_object( + 'usedTokens', sequence * 100, + 'modelContextWindow', 100000 + ) + ELSE json_object('sequence', sequence) + END, sequence, '2026-03-01T00:04:00.000Z' FROM activity_rows @@ -2469,12 +2545,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); assert.equal(detailWithPinnedRequests._tag, "Some"); if (detailWithPinnedRequests._tag === "Some") { - const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + const ids = new Set( + detailWithPinnedRequests.value.activities.map((activity) => activity.id), + ); assert.equal(detailWithPinnedRequests.value.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); } const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { @@ -2482,12 +2560,67 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }); assert.equal(windowWithPinnedRequests._tag, "Some"); if (windowWithPinnedRequests._tag === "Some") { - const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + const ids = new Set( + windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id), + ); assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); + } + + const fullSnapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(fullSnapshot._tag, "Some"); + if ( + detailWithPinnedRequests._tag === "Some" && + fullSnapshot._tag === "Some" && + windowWithPinnedRequests._tag === "Some" + ) { + const projectedFullSnapshot = projectThreadDetailSnapshot(fullSnapshot.value); + const projectedRawBaseline = projectThreadDetailSnapshot({ + snapshotSequence: fullSnapshot.value.snapshotSequence, + thread: detailWithPinnedRequests.value, + }); + assert.deepStrictEqual(projectedFullSnapshot, projectedRawBaseline); + + const rawActivitiesById = new Map( + detailWithPinnedRequests.value.activities.map((activity) => [activity.id, activity]), + ); + const projectedWindowSnapshot = projectThreadDetailSnapshot(windowWithPinnedRequests.value); + const projectedWindowBaseline = projectThreadDetailSnapshot({ + ...windowWithPinnedRequests.value, + thread: { + ...windowWithPinnedRequests.value.thread, + activities: windowWithPinnedRequests.value.thread.activities.map( + (activity) => rawActivitiesById.get(activity.id) ?? activity, + ), + }, + }); + assert.deepStrictEqual(projectedWindowSnapshot, projectedWindowBaseline); + + const projectedIds = new Set( + projectedFullSnapshot.thread.activities.map((activity) => activity.id), + ); + assert.equal(projectedIds.has(asEventId("activity-0002")), false); + assert.equal(projectedIds.has(asEventId("activity-0003")), false); + assert.equal(projectedIds.has(asEventId("activity-0070")), true); + + const failedCommand = projectedFullSnapshot.thread.activities.find( + (activity) => activity.id === asEventId("activity-0011"), + ); + assert.deepStrictEqual(failedCommand?.payload, { + itemType: "command_execution", + status: "failed", + data: { + item: { + command: "vp test run", + aggregatedOutput: "failed command", + }, + files: [{ path: "apps/server/src/failed.ts" }], + rawOutput: { content: "failed output" }, + }, + }); } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d3270b0ce521..ea808f18d3a7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -57,6 +57,7 @@ import { decodeThreadDetailPageCursor, encodeThreadDetailPageCursor, } from "../threadDetailCursor.ts"; +import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -75,6 +76,9 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. const THREAD_DETAIL_ACTIVITY_LIMIT = 500; +// Snapshot payloads are decoded and projected in small sequential batches so +// one client read does not retain the raw payloads for the full activity window. +const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -100,6 +104,9 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( sequence: Schema.NullOr(NonNegativeInt), }), ); +const ProjectionThreadActivityIdRowSchema = Schema.Struct({ + activityId: ProjectionThreadActivity.fields.activityId, +}); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ @@ -146,6 +153,9 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ threadId: ThreadId, activityKinds: Schema.Array(Schema.String), }); +const ThreadActivityIdsLookupInput = Schema.Struct({ + activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), +}); // Windowed reads order turns by the stable keyset (anchor, turn key), where // anchor is requested_at and turn key is // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the @@ -349,6 +359,21 @@ function mapProposedPlanRow( }; } +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + return { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -1060,6 +1085,44 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + + const listThreadActivityRowsByIds = SqlSchema.findAll({ + Request: ThreadActivityIdsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ activityIds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + -- The selectors already scoped these globally unique ids to the + -- thread inside this transaction. Keep this as a primary-key lookup. + WHERE ${sql.in("activity_id", activityIds)} + `, + }); + const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ Request: ThreadActivityKindsLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1310,15 +1373,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - // Blocking request payloads must remain available even if they predate the - // recent activity window. Each CTE returns at most one unresolved row per - // request, so the merge below stays bounded by actionable work. - const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ - Request: ThreadIdLookupInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => - sql` - WITH pending_approval_requests AS ( + const pinnedThreadActivityIdsCte = (threadId: string) => sql` +pending_approval_requests AS ( SELECT request_id, thread_id FROM projection_pending_approvals WHERE thread_id = ${threadId} @@ -1382,6 +1438,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE request_order = 1 AND kind = 'user-input.requested' ) + `; + + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} SELECT activity.activity_id AS "activityId", activity.thread_id AS "threadId", @@ -1399,6 +1466,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} + SELECT activity_id AS "activityId" + FROM pinned_activity_ids + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1466,6 +1544,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -2571,49 +2691,136 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly beforeTurnKey: string; } - const getThreadDetailByIdBounded = ( - threadId: ThreadId, - bounds: ThreadDetailBounds | undefined, - query: ProjectionThreadDetailQuery | undefined = undefined, - ) => - Effect.gen(function* () { - const activityRowsEffect = ( - query?.activityKinds === undefined - ? bounds === undefined - ? listThreadActivityRowsByThread({ threadId }) - : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) - : query.activityKinds.length === 0 - ? Effect.succeed([]) - : listThreadActivityRowsByThreadAndKinds({ - threadId, - activityKinds: query.activityKinds, - }) + type ThreadDetailActivityRead = + | { + readonly mode: "raw"; + readonly query?: ProjectionThreadDetailQuery; + } + | { + readonly mode: "client"; + }; + + const listProjectedThreadActivities = Effect.fn( + "ProjectionSnapshotQuery.listProjectedThreadActivities", + )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) { + const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([ + (bounds === undefined + ? listThreadActivityIdsByThread({ threadId }) + : listThreadActivityIdsByThreadWindow({ threadId, ...bounds }) ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", ), ), + ), + listPinnedThreadActivityIdsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows", + ), + ), + ), + ]); + const activityIds = [ + ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)), + ]; + const activities: OrchestrationThreadActivity[] = []; + + for ( + let offset = 0; + offset < activityIds.length; + offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE + ) { + const batchIds = activityIds.slice( + offset, + offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE, ); - const pinnedActivityRowsEffect = - query?.activityKinds === undefined - ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows", + ), + ), + ); + for (const row of batchRows) { + activities.push(projectActivityPayload(mapThreadActivityRow(row))); + } + } + + return activities.toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + ); + }); + + const getThreadDetailByIdBounded = ( + threadId: ThreadId, + bounds: ThreadDetailBounds | undefined, + activityRead: ThreadDetailActivityRead = { mode: "raw" }, + ) => + Effect.gen(function* () { + const activitiesEffect = + activityRead.mode === "client" + ? listProjectedThreadActivities(threadId, bounds) + : Effect.all([ + (activityRead.query?.activityKinds === undefined + ? bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + : activityRead.query.activityKinds.length === 0 + ? Effect.succeed([]) + : listThreadActivityRowsByThreadAndKinds({ + threadId, + activityKinds: activityRead.query.activityKinds, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), ), ), - ) - : Effect.succeed([]); + activityRead.query?.activityKinds === undefined + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ) + : Effect.succeed([]), + ]).pipe( + Effect.map(([activityRows, pinnedActivityRows]) => + [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map( + (row) => [row.activityId, row] as const, + ), + ).values(), + ] + .toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ) + .map(mapThreadActivityRow), + ), + ); const [ threadRow, messageRows, proposedPlanRows, - activityRows, - pinnedActivityRows, + activities, checkpointRows, latestTurnRow, sessionRow, @@ -2645,8 +2852,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - activityRowsEffect, - pinnedActivityRowsEffect, + activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2677,17 +2883,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } - const selectedActivityRows = [ - ...new Map( - [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), - ).values(), - ].toSorted( - (left, right) => - (left.sequence ?? -1) - (right.sequence ?? -1) || - left.createdAt.localeCompare(right.createdAt) || - left.activityId.localeCompare(right.activityId), - ); - const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2729,21 +2924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: selectedActivityRows.map((row) => { - const activity = { - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }; - if (row.sequence !== null) { - return Object.assign(activity, { sequence: row.sequence }); - } - return activity; - }), + activities, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2768,7 +2949,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( threadId, query, - ) => getThreadDetailByIdBounded(threadId, undefined, query); + ) => + getThreadDetailByIdBounded(threadId, undefined, { + mode: "raw", + ...(query === undefined ? {} : { query }), + }); // Bounds pathological fan-out: one user turn that spawned hundreds of // subagent turns still pages in bounded chunks, at the cost of splitting the @@ -2792,7 +2977,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { .withTransaction( Effect.gen(function* () { if (window?.turnLimit === undefined) { - const thread = yield* getThreadDetailById(threadId); + const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } @@ -2845,7 +3032,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } : undefined; - const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index f783d4d07bba..9428e84747cd 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -191,6 +191,10 @@ export interface ProjectionSnapshotQueryShape { * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). * Without a window the full thread is returned with no `page` field — * pagination is strictly opt-in. + * + * Activity payloads are projected for clients as they are read in small + * sequential batches. Callers still apply the full snapshot projector for + * collection-level activity pruning. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, From 0bfb6df34b26dfe0162db6c09dca00bc8c5a5ec4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 02:12:35 -0700 Subject: [PATCH 33/42] perf(server): cut idle CPU use and stop provider event leaks (#8187) --- .../Layers/ProviderRuntimeIngestion.test.ts | 23 ++ .../Layers/ProviderRuntimeIngestion.ts | 18 +- .../RepositoryIdentityResolver.test.ts | 84 ++++++ .../src/project/RepositoryIdentityResolver.ts | 32 ++- .../provider/Layers/EventNdjsonLogger.test.ts | 44 ++- .../src/provider/Layers/EventNdjsonLogger.ts | 61 ++++- .../provider/Layers/OpenCodeAdapter.test.ts | 15 ++ .../src/provider/Layers/OpenCodeAdapter.ts | 6 +- .../src/provider/acp/AcpNativeLogging.test.ts | 169 ++++++++++++ .../src/provider/acp/AcpNativeLogging.ts | 67 ++++- .../NativeTelemetryClient.test.ts | 5 +- .../NativeTelemetryClient.ts | 40 +-- apps/server/src/vcs/GitVcsDriverCore.ts | 3 +- docs/internals/resource-telemetry.md | 9 +- native/resource-monitor/src/main.rs | 35 ++- packages/effect-acp/src/protocol.test.ts | 35 +++ packages/effect-acp/src/protocol.ts | 18 +- .../src/protocol.test.ts | 252 ++++++++++++++++++ .../effect-codex-app-server/src/protocol.ts | 95 +++++-- 19 files changed, 913 insertions(+), 98 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 84858b6affe9..26332f9f8c9c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -973,6 +973,29 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("ignores provider content deltas that cannot change thread state", async () => { + const harness = await createHarness(); + const initial = await harness.readModel(); + + for (const streamKind of ["reasoning_text", "command_output", "file_change_output"] as const) { + harness.emit({ + type: "content.delta", + eventId: asEventId(`evt-ignored-${streamKind}`), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ignored"), + payload: { + streamKind, + delta: "ignored output", + }, + }); + } + + await harness.drain(); + expect(await harness.readModel()).toEqual(initial); + }); + it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 198f163d8a89..a90010f0b6e2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1499,6 +1499,10 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { + if (event.type === "content.delta" && event.payload.streamKind !== "assistant_text") { + return; + } + const thread = yield* resolveThreadShell(event.threadId); if (!thread) return; @@ -1515,9 +1519,17 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }); + const pendingTurnStart = + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }) + : Option.none(); const hasPendingTurnStart = Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index a997459e63d7..72232a78b689 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -5,6 +5,7 @@ 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 ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; @@ -35,6 +36,89 @@ const makeRepositoryIdentityResolverTestLayer = (options: { ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("reuses the cached Git root for repeated workspace lookups", () => { + const calls: Array> = []; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + return { + stdout: input.args.includes("rev-parse") + ? "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const first = yield* resolver.resolve("/repo/packages/web"); + const second = yield* resolver.resolve("/repo/packages/web"); + + expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(second).toEqual(first); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + + it.effect("retries Git root discovery after a failed lookup", () => { + const calls: Array> = []; + let rootAttempts = 0; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + const rootLookup = input.args.includes("rev-parse"); + const failed = rootLookup && rootAttempts++ === 0; + return { + stdout: rootLookup + ? failed + ? "" + : "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: failed ? "temporary Git failure" : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + + const recovered = yield* resolver.resolve("/repo/packages/web"); + expect(recovered?.rootPath).toBe("/repo"); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c7..bf3c570c3cac 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -90,7 +90,6 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -102,15 +101,11 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + return null; } const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; + return candidate.length > 0 ? candidate : null; }, ); @@ -139,6 +134,22 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + + const repositoryRootCache = yield* Cache.makeWith( + (cwd) => + resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: cacheCapacity, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => @@ -146,7 +157,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null @@ -160,9 +171,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index f6fb557e4b43..c072e6e5148f 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -286,7 +286,7 @@ describe("EventNdjsonLogger", () => { }), ); - it.effect("drops transient canonical events before serialization", () => + it.effect("drops transient provider events before serialization", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); @@ -302,6 +302,46 @@ describe("EventNdjsonLogger", () => { yield* canonical.write(circularDelta, threadId); yield* canonical.write({ type: "item.completed", id: "final" }, threadId); yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); + yield* native.write( + { method: "item/agentMessage/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/transcript/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { + event: { + method: "claude/stream_event/content_block_delta/text_delta", + payload: circularDelta, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + method: "session/update", + payload: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + type: "message.part.updated", + payload: { properties: { part: { type: "text" } } }, + }, + }, + threadId, + ); + yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); yield* store.close(); const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") @@ -313,7 +353,7 @@ describe("EventNdjsonLogger", () => { lines.map(({ stream, payload }) => ({ stream, payload })), [ { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, + { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, ], ); } finally { diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index e07121ea76c1..241eddb3b9cb 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -45,6 +45,17 @@ const transientCanonicalEventTypes = new Set([ "tool.progress", "turn.proposed.delta", ]); +const transientNativeMethods = new Set([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/plan/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "thread/realtime/outputAudio/delta", + "thread/realtime/transcript/delta", +]); +const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); export type EventNdjsonStream = "native" | "canonical" | "orchestration"; @@ -126,7 +137,7 @@ export interface PendingRecord { } interface StoreState { - readonly pending: ReadonlyArray; + readonly pending: Array; readonly pendingBytes: number; readonly sinks: ReadonlyMap; readonly flushScheduled: boolean; @@ -178,12 +189,50 @@ function providerLogPath(directory: string, prefix: string, threadSegment: strin } function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { - if (stream !== "canonical" || typeof event !== "object" || event === null) { + if (stream === "orchestration" || typeof event !== "object" || event === null) { return true; } try { const type = Reflect.get(event, "type"); - return typeof type !== "string" || !transientCanonicalEventTypes.has(type); + if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { + return false; + } + if (stream !== "native") return true; + + const nested = Reflect.get(event, "event"); + const nativeEvent = typeof nested === "object" && nested !== null ? nested : event; + const method = Reflect.get(nativeEvent, "method"); + if ( + typeof method === "string" && + (transientNativeMethods.has(method) || + method.startsWith("claude/stream_event/content_block_delta/")) + ) { + return false; + } + + const nativeType = Reflect.get(nativeEvent, "type"); + if (nativeType === "message.part.delta") return false; + + const payload = Reflect.get(nativeEvent, "payload"); + if (typeof payload !== "object" || payload === null) return true; + + if (method === "session/update") { + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return true; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); + } + + if (nativeType === "message.part.updated") { + const properties = Reflect.get(payload, "properties"); + if (typeof properties !== "object" || properties === null) return true; + const part = Reflect.get(properties, "part"); + if (typeof part !== "object" || part === null) return true; + const partType = Reflect.get(part, "type"); + return partType !== "text" && partType !== "reasoning"; + } + + return true; } catch { return true; } @@ -566,10 +615,8 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( if (state.closed) { return Effect.succeed([{ flush: false }, state] as const); } - const pending = [ - ...state.pending, - { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, - ]; + const pending = state.pending; + pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); const pendingBytes = state.pendingBytes + bytes; const flush = resolved.batchWindowMs === 0 || diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9823a68708c2..0ba4b2d1ee3d 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -4607,12 +4607,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { 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(appendedUpdate, { + latestText: "Hello world", + deltaToEmit: " world", + }); + NodeAssert.deepEqual(changedUpdate, { + latestText: "Hello there", + deltaToEmit: "there", + }); + NodeAssert.deepEqual(staleUpdate, { + latestText: "Hello world", + deltaToEmit: "", + }); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index c049eedb62b5..606dcde6ce9e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -565,9 +565,13 @@ export function mergeOpenCodeAssistantText( readonly deltaToEmit: string; } { const latestText = resolveLatestAssistantText(previousText, nextText); + const previous = previousText ?? ""; + const prefixLength = latestText.startsWith(previous) + ? previous.length + : commonPrefixLength(previous, latestText); return { latestText, - deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + deltaToEmit: latestText.slice(prefixLength), }; } diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 7c949e040599..84926fbe1d61 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -28,6 +28,7 @@ nodeServicesIt("ACP native logging", (it) => { nativeEventLogger, provider: ProviderDriverKind.make("cursor"), threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, }); const secret = "secret-token-value"; const requestLogger = logger.requestLogger; @@ -67,6 +68,174 @@ nodeServicesIt("ACP native logging", (it) => { }), ); + it.effect("keeps request diagnostics without enabling full protocol logging", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + }); + + assert.isUndefined(logger.protocolLogging); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: updateType } }, + })}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: updateType } }, + }, + ], + }); + } + + assert.lengthOf(records, 0); + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "tool_call" } }, + }, + ], + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("keeps mixed and incomplete raw diagnostics", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + const transient = encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }); + const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); + + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n${lifecycle}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: transient, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n{malformed}\n`, + }); + + assert.lengthOf(records, 3); + }), + ); + + it.effect("filters transient entries from mixed decoded batches", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "agent_thought_chunk" } }, + }, + { + _tag: "Request", + tag: "session/new", + payload: {}, + }, + ], + }); + + assert.lengthOf(records, 1); + assert.include(encodeUnknownJson(records), '"itemCount":1'); + }), + ); + it.effect("logs a structural tag when the native writer defects", () => { const messages: Array = []; const logCapture = Logger.make(({ message }) => { diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 06bff3aa6113..6d1bf6209d5d 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -9,6 +9,8 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); + function structuralMethod(value: string): string { return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; } @@ -64,12 +66,61 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) }; } +function isTransientProtocolMessage(message: unknown): boolean { + if (typeof message !== "object" || message === null) return false; + const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); + if (method !== "session/update") return false; + + const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); + if (typeof payload !== "object" || payload === null) return false; + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return false; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType === "string" && transientProtocolUpdates.has(updateType); +} + +function rawChunkContainsOnlyTransientMessages(payload: string): boolean { + const lines = payload.split("\n"); + const remainder = lines.pop() ?? ""; + if (remainder.trim().length > 0) return false; + + const messages: Array = []; + for (const line of lines) { + if (line.trim().length === 0) continue; + try { + messages.push(JSON.parse(line)); + } catch { + return false; + } + } + return messages.length > 0 && messages.every(isTransientProtocolMessage); +} + +function filterTransientProtocolLog( + event: EffectAcpProtocol.AcpProtocolLogEvent, +): EffectAcpProtocol.AcpProtocolLogEvent | undefined { + if (event.direction !== "incoming") return event; + + if (event.stage === "raw" && typeof event.payload === "string") { + return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; + } + + if (event.stage !== "decoded") return event; + if (!Array.isArray(event.payload)) { + return isTransientProtocolMessage(event.payload) ? undefined : event; + } + + const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); + return payload.length === 0 ? undefined : { ...event, payload }; +} + export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { const crypto = yield* Crypto.Crypto; return (input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; + readonly verboseProtocolLogging?: boolean; }): Pick => { const writeNativeAcpLog = (logInput: { readonly kind: "request" | "protocol"; @@ -111,16 +162,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" kind: "request", payload: formatRequestLogPayload(event), }), - ...(input.nativeEventLogger + ...(input.nativeEventLogger && input.verboseProtocolLogging ? { protocolLogging: { logIncoming: true, logOutgoing: true, - logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => - writeNativeAcpLog({ - kind: "protocol", - payload: formatProtocolLogPayload(event), - }), + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { + const filtered = filterTransientProtocolLog(event); + return filtered + ? writeNativeAcpLog({ + kind: "protocol", + payload: formatProtocolLogPayload(filtered), + }) + : Effect.void; + }, } satisfies NonNullable, } : {}), diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 61a67d116069..8a595bc8b480 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -44,7 +44,7 @@ describe("resolveNativeSampleIntervalMs", () => { expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); }); - it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { + it("slows background telemetry and serves live diagnostics at 1Hz", () => { const unknown: HostPowerSnapshot = { ...basePower, source: "unknown", @@ -58,7 +58,8 @@ describe("resolveNativeSampleIntervalMs", () => { 0, ), ).toBe(5_000); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); + expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(5_000); + expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); }); }); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index e8d81cc4c1c0..232079d9dc9b 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -268,7 +268,7 @@ export function resolveNativeSampleIntervalMs( return CONSTRAINED_SAMPLE_INTERVAL_MS; } if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; - return SAMPLE_INTERVAL_MS; + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; } export function commitCollectionControlUpdate( @@ -462,13 +462,16 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu return Effect.gen(function* () { const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: Option.some(sampledAt), - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: Option.some(sampledAt), + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; yield* PubSub.publish(snapshots, nativeSnapshot); if (event.requestId) { const deferred = yield* Ref.modify(pendingSamples, (pending) => { @@ -485,15 +488,18 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu case "historyChunk": return Effect.gen(function* () { const latestSnapshot = event.snapshots.at(-1); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: latestSnapshot - ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) - : current.lastSampleAt, - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: latestSnapshot + ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) + : current.lastSampleAt, + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; const completed = yield* Ref.modify(pendingHistories, (pending) => { const request = pending.get(event.requestId); if (!request) return [Option.none(), pending] as const; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 71e478cbaa3d..ef2d00291caf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -475,6 +475,7 @@ function trace2ChildKey(record: Record): string | null { } const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); +const decodeTrace2Record = decodeJsonResult(Trace2Record); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( input: Pick, @@ -509,7 +510,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); + const traceRecord = decodeTrace2Record(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, diff --git a/docs/internals/resource-telemetry.md b/docs/internals/resource-telemetry.md index 0d07f31f8ac5..0f3e6674ff79 100644 --- a/docs/internals/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -103,9 +103,9 @@ power-adaptive interval selected by the server. It collects: - resident and virtual memory; - cumulative process I/O counters. -On Linux, task/thread enumeration is disabled. Command lines are loaded only -when first needed. This avoids the expensive default behavior of walking every -`/proc//task/` directory on each refresh. +On Linux, task/thread enumeration is disabled. Command lines are refreshed with +each sample so process replacements remain visible. Disabling task enumeration +avoids walking every `/proc//task/` directory on each refresh. ### Process-tree selection @@ -142,7 +142,8 @@ The server adjusts native sampling without restarting the sidecar: - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; - battery: 5 seconds; -- normal AC: 1 second; +- normal AC: 5 seconds in the background and 1 second while live diagnostics is + open; - unknown or stale power: 5 seconds in the background and 1 second while live diagnostics is open. diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs index 0e5dd66307b9..0596aea977cd 100644 --- a/native/resource-monitor/src/main.rs +++ b/native/resource-monitor/src/main.rs @@ -250,6 +250,10 @@ impl HistoryRecorder { max_retained_entries: usize, max_retained_bytes: usize, ) { + let clock_moved_backward = self + .snapshots + .back() + .is_some_and(|previous| previous.sampled_at_unix_ms > snapshot.sampled_at_unix_ms); let mut retained = snapshot.clone(); retained.request_id = None; self.retained_entry_count = self @@ -264,6 +268,7 @@ impl HistoryRecorder { max_snapshots, max_retained_entries, max_retained_bytes, + clock_moved_backward, ); } @@ -273,20 +278,24 @@ impl HistoryRecorder { max_snapshots: usize, max_retained_entries: usize, max_retained_bytes: usize, + clock_moved_backward: bool, ) { - let mut future_entry_count = 0usize; - let mut future_bytes = 0usize; - self.snapshots.retain(|snapshot| { - let keep = snapshot.sampled_at_unix_ms <= now_ms; - if !keep { - future_entry_count = - future_entry_count.saturating_add(snapshot.retained_entry_count()); - future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); - } - keep - }); - self.retained_entry_count = self.retained_entry_count.saturating_sub(future_entry_count); - self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); + if clock_moved_backward { + let mut future_entry_count = 0usize; + let mut future_bytes = 0usize; + self.snapshots.retain(|snapshot| { + let keep = snapshot.sampled_at_unix_ms <= now_ms; + if !keep { + future_entry_count = + future_entry_count.saturating_add(snapshot.retained_entry_count()); + future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); + } + keep + }); + self.retained_entry_count = + self.retained_entry_count.saturating_sub(future_entry_count); + self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); + } while self.snapshots.front().is_some_and(|snapshot| { snapshot.sampled_at_unix_ms < now_ms.saturating_sub(HISTORY_RETENTION_MS) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index a66cc75225da..7ca86b063e23 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -135,6 +135,41 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "x/performance", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incoming.pipe(Stream.take(32), Stream.runCollect); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + it.effect("keeps invalid core notification values only in the schema cause", () => Effect.gen(function* () { const secret = "acp-core-notification-secret-sentinel"; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index d61641fbb7b5..44a48bd1ef76 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -76,6 +76,7 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, ); const parserFactory = RpcSerialization.ndJsonRpc(); +const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( options: AcpPatchedProtocolOptions, @@ -83,7 +84,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const parser = parserFactory.makeUnsafe(); const serverQueue = yield* Queue.unbounded(); const clientQueue = yield* Queue.unbounded(); - const notificationQueue = yield* Queue.unbounded(); + const notificationQueue = yield* Queue.sliding( + MAX_BUFFERED_RAW_NOTIFICATIONS, + ); const disconnects = yield* Queue.unbounded(); const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1); @@ -408,11 +411,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi yield* options.stdio.stdin.pipe( Stream.runForEach((data) => - logProtocol({ - direction: "incoming", - stage: "raw", - payload: typeof data === "string" ? data : new TextDecoder().decode(data), - }).pipe( + (options.logIncoming + ? logProtocol({ + direction: "incoming", + stage: "raw", + payload: typeof data === "string" ? data : new TextDecoder().decode(data), + }) + : Effect.void + ).pipe( Effect.flatMap(() => Effect.try({ try: () => diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index c0eacf1cf757..7249afff1071 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -398,6 +399,257 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + method: "item/agentMessage/delta", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incomingNotifications.pipe( + Stream.take(32), + Stream.runCollect, + ); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + + it.effect("keeps processing protocol messages while an approval is pending", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalDecision = yield* Deferred.make<{ readonly decision: string }>(); + const notificationReceived = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Deferred.await(approvalDecision)), + ), + onNotification: () => Deferred.succeed(notificationReceived, undefined).pipe(Effect.asVoid), + }); + + const pendingRequest = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ id: 7, method: "item/tool/requestUserInput", params: {} }), + encodeUnknownJsonString({ method: "item/agentMessage/delta", params: { delta: "ok" } }), + encodeUnknownJsonString({ id: 1, result: { threadId: "thread-1" } }), + ].join("\n")}\n`, + ), + ); + + yield* Deferred.await(approvalStarted); + yield* Deferred.await(notificationReceived); + assert.deepEqual(yield* Fiber.join(pendingRequest), { threadId: "thread-1" }); + + yield* Deferred.succeed(approvalDecision, { decision: "accept" }); + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 7, + result: { decision: "accept" }, + }); + }), + ); + + it.effect("rejects incoming requests after the active handler limit is reached", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlersStarted = yield* Deferred.make(); + const releaseHandlers = yield* Deferred.make(); + let activeHandlers = 0; + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++activeHandlers).pipe( + Effect.flatMap((count) => + count === 32 + ? Deferred.succeed(handlersStarted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.andThen(Deferred.await(releaseHandlers)), + Effect.as({ decision: "accept" }), + ), + }); + + const requests = Array.from({ length: 33 }, (_, index) => + encodeUnknownJsonString({ + id: index + 1, + method: "item/tool/requestUserInput", + params: {}, + }), + ); + yield* Queue.offer(input, encoder.encode(`${requests.join("\n")}\n`)); + yield* Deferred.await(handlersStarted); + + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 33, + error: { + code: -32001, + message: "Too many Codex requests are already active.", + }, + }); + assert.equal(activeHandlers, 32); + + yield* Deferred.succeed(releaseHandlers, undefined); + yield* Effect.forEach(Array.from({ length: 32 }), () => Queue.take(output), { + discard: true, + }); + }), + ); + + it.effect("interrupts pending request handlers when the protocol terminates", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalInterrupted = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(approvalInterrupted, undefined).pipe(Effect.asVoid), + ), + ), + onTermination: () => Deferred.succeed(terminated, undefined).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + yield* Deferred.await(approvalStarted); + yield* Queue.end(input); + + yield* Deferred.await(approvalInterrupted); + yield* Deferred.await(terminated); + }), + ); + + it.effect("rejects outgoing messages after an approval response cannot be encoded", () => + Effect.gen(function* () { + const { stdio: baseStdio, input } = yield* makeInMemoryStdio(); + const terminated = yield* Deferred.make(); + const readerStopped = yield* Deferred.make(); + let notificationCount = 0; + let requestCount = 0; + const stdio = Stdio.make({ + args: baseStdio.args, + stdin: baseStdio.stdin.pipe( + Stream.ensuring(Deferred.succeed(readerStopped, undefined).pipe(Effect.asVoid)), + ), + stdout: baseStdio.stdout, + stderr: baseStdio.stderr, + }); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++requestCount).pipe( + Effect.map((count) => (count === 1 ? { invalid: 1n } : { ok: true })), + ), + onNotification: () => Effect.sync(() => notificationCount++).pipe(Effect.asVoid), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + + const failure = yield* Deferred.await(terminated); + assert.instanceOf(failure, CodexError.CodexAppServerProtocolParseError); + const requestFailure = yield* transport.request("thread/read", {}).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected a terminated protocol request to fail"), + }), + ); + const notificationFailure = yield* transport.notify("initialized").pipe(Effect.flip); + assert.strictEqual(requestFailure, failure); + assert.strictEqual(notificationFailure, failure); + yield* Deferred.await(readerStopped); + + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ method: "x/late-notification" }), + encodeUnknownJsonString({ id: 8, method: "x/late-request" }), + ].join("\n")}\n`, + ), + ); + + assert.equal(notificationCount, 0); + assert.equal(requestCount, 1); + assert.equal(yield* Queue.size(input), 1); + }), + ); + + it.effect("fails pending requests before interrupted handler cleanup completes", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlerStarted = yield* Deferred.make(); + const finalizerStarted = yield* Deferred.make(); + const releaseFinalizer = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(handlerStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(finalizerStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFinalizer)), + ), + ), + ), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + const pending = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer(input, encodeJsonl({ id: 7, method: "x/approval" })); + yield* Deferred.await(handlerStarted); + yield* Queue.end(input); + + const failure = yield* Deferred.await(terminated); + yield* Deferred.await(finalizerStarted); + const pendingFailure = yield* Fiber.join(pending).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected the pending request to fail"), + }), + ); + assert.strictEqual(pendingFailure, failure); + + yield* Deferred.succeed(releaseFinalizer, undefined); + }), + ); + it.effect("surfaces JSON encoding failures as protocol parse errors", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 2f604c866e5c..4a32973a988b 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -1,6 +1,8 @@ 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 Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -13,6 +15,7 @@ import { JsonRpcId, JsonRpcResponseEnvelope } from "./_internal/shared.ts"; const isJsonRpcId = Schema.is(JsonRpcId); const isJsonRpcResponseEnvelope = Schema.is(JsonRpcResponseEnvelope); const isCodexAppServerError = Schema.is(CodexError.CodexAppServerError); +const MAX_BUFFERED_RAW_MESSAGES = 32; export interface CodexAppServerProtocolLogEvent { readonly direction: "incoming" | "outgoing"; @@ -152,13 +155,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa function* ( options: CodexAppServerPatchedProtocolOptions, ): Effect.fn.Return { + const protocolScope = yield* Scope.Scope; + const requestHandlerScope = yield* Scope.fork(protocolScope, "parallel"); const outgoing = yield* Queue.unbounded>(); - const incomingNotifications = yield* Queue.unbounded(); - const incomingRequests = yield* Queue.unbounded(); + const incomingNotifications = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); + const incomingRequests = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); const remainder: Array = []; const terminationHandled = yield* Ref.make(false); + const terminationFailure = yield* Ref.make(Option.none()); + const terminationSignal = yield* Deferred.make(); + const activeRequestHandlers = yield* Ref.make(0); const logProtocol = (event: CodexAppServerProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -191,8 +201,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return [ Effect.gen(function* () { const error = yield* classify(); + yield* Ref.set(terminationFailure, Option.some(error)); yield* failAllPending(error); yield* Queue.end(outgoing); + yield* Deferred.succeed(terminationSignal, undefined); + yield* Scope.close(requestHandlerScope, Exit.void).pipe( + Effect.forkIn(protocolScope, { startImmediately: true }), + Effect.asVoid, + ); if (options.onTermination) { yield* options.onTermination(error); } @@ -203,6 +219,9 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const offerOutgoing = (message: Record) => Effect.gen(function* () { + const failure = yield* Ref.get(terminationFailure); + if (Option.isSome(failure)) return yield* failure.value; + yield* logProtocol({ direction: "outgoing", stage: "decoded", @@ -214,7 +233,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa stage: "raw", payload: encoded, }); - yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); + const accepted = yield* Queue.offer(outgoing, encoded); + if (!accepted) { + const closed = yield* Ref.get(terminationFailure); + return yield* Option.getOrElse( + closed, + () => new CodexError.CodexAppServerInputStreamEndedError({}), + ); + } }); const removePending = (requestId: string) => @@ -271,9 +297,24 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const handleRequest = (request: CodexAppServerIncomingRequest) => Queue.offer(incomingRequests, request).pipe( - Effect.andThen( - options.onRequest - ? options.onRequest(request).pipe( + Effect.flatMap(() => { + const handler = options.onRequest; + if (!handler) return Effect.void; + + return Ref.modify(activeRequestHandlers, (count) => + count >= MAX_BUFFERED_RAW_MESSAGES ? [false, count] : [true, count + 1], + ).pipe( + Effect.flatMap((accepted) => { + if (!accepted) { + return respondError( + request.id, + CodexError.CodexAppServerRequestError.overloaded( + "Too many Codex requests are already active.", + ), + ); + } + + return handler(request).pipe( Effect.matchEffect({ onFailure: (error) => respondError( @@ -285,9 +326,21 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa ), onSuccess: (result) => respond(request.id, result), }), - ) - : Effect.void, - ), + Effect.ensuring( + Ref.update(activeRequestHandlers, (count) => Math.max(0, count - 1)), + ), + Effect.catch((error) => + handleTermination(() => Effect.succeed(error)).pipe( + Effect.forkIn(protocolScope), + Effect.asVoid, + ), + ), + Effect.forkIn(requestHandlerScope, { startImmediately: true }), + Effect.asVoid, + ); + }), + ); + }), Effect.asVoid, ); @@ -297,22 +350,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.asVoid, ); - const routeMessage = ( - message: unknown, - ): Effect.Effect => { - if (isIncomingRequest(message)) { - return handleRequest(message); - } - if (isIncomingNotification(message)) { - return handleNotification(message); - } - if (isIncomingResponse(message)) { - return handleResponse(message); - } - return Effect.fail( - CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), - ); - }; + const routeMessage = Effect.fnUntraced(function* (message: unknown) { + if (Option.isSome(yield* Ref.get(terminationFailure))) return; + if (isIncomingRequest(message)) return yield* handleRequest(message); + if (isIncomingNotification(message)) return yield* handleNotification(message); + if (isIncomingResponse(message)) return yield* handleResponse(message); + return yield* CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message); + }); const handleLine = (line: string): Effect.Effect => { if (line.trim().length === 0) { @@ -352,6 +396,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa }; yield* options.stdio.stdin.pipe( + Stream.interruptWhen(Deferred.await(terminationSignal)), Stream.decodeText(), Stream.runForEach((chunk) => Effect.sync(() => { From 8f1ef8b9eb72ac6cd8510364608e01dc7febf9c1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:59:16 +0200 Subject: [PATCH 34/42] perf(server): scan only appended transcript bytes for usage summaries (#9024) Co-authored-by: Claude Fable 5 Co-authored-by: Theo Browne --- apps/server/src/usage/UsageService.test.ts | 226 ++++++++++++++++++ apps/server/src/usage/UsageService.ts | 165 ++++++++++--- apps/server/src/usage/usageScanCache.test.ts | 75 +++++- apps/server/src/usage/usageScanCache.ts | 176 +++++++++++--- .../src/usage/usageTranscriptReader.test.ts | 210 ++++++++++++++++ .../server/src/usage/usageTranscriptReader.ts | 201 ++++++++++++++-- 6 files changed, 963 insertions(+), 90 deletions(-) create mode 100644 apps/server/src/usage/UsageService.test.ts create mode 100644 apps/server/src/usage/usageTranscriptReader.test.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 000000000000..8fc86ee3d462 --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real +// transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scheduler from "effect/Scheduler"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as UsageService from "./UsageService.ts"; + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +const WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-07-31"), + untilDay: UsageDay.make("2026-08-02"), +}; + +const setup = Effect.gen(function* () { + const home = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(home, { recursive: true, force: true })), + ); + const transcriptDir = NodePath.join(home, "claude", "projects", "proj"); + yield* Effect.promise(() => NodeFSP.mkdir(transcriptDir, { recursive: true })); + return { + home, + transcript: NodePath.join(transcriptDir, "session.jsonl"), + settings: { + providers: { + claudeAgent: { homePath: NodePath.join(home, "claude") }, + codex: { homePath: NodePath.join(home, "codex") }, + }, + }, + }; +}); + +const serviceLayers = (input: { + readonly prefix: string; + readonly home: string; + readonly settings: Parameters[0]; + readonly onRatesFetch?: () => void; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + input.onRatesFetch?.(); + // Unparsable rates: every scan retries the fetch, which makes the + // fetch count a boundary-level observation of how many scans ran. + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + ), + ), + ), + Layer.provideMerge( + Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + ), + ); + +function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { + return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); +} + +describe("UsageService", () => { + it.live("counts appended usage on a rescan of a grown transcript", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const second = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("shares one scan between concurrent identical requests", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-flight-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const [first, second] = yield* Effect.all( + [service.readSummary(WINDOW), service.readSummary(WINDOW)], + { concurrency: 2 }, + ); + assert.deepStrictEqual(first, second); + assert.strictEqual(ratesFetches, 1); + + // A later request is fresh work again, not a stale cached answer. + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 2); + }).pipe(Effect.scoped), + ); + + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-interruption-test", home, settings }), + ), + ); + + let orphanedAt: number | undefined; + for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { + const tasks: Array<() => void> = []; + const dispatcher: Scheduler.SchedulerDispatcher = { + scheduleTask: (task) => tasks.push(task), + flush: () => { + let task: (() => void) | undefined; + while ((task = tasks.shift()) !== undefined) task(); + }, + }; + + let requestFiber: Fiber.Fiber | undefined; + let requestChecks = 0; + const scheduler: Scheduler.Scheduler = { + executionMode: "async", + makeDispatcher: () => dispatcher, + shouldYield: (fiber) => { + if (fiber !== requestFiber) return false; + requestChecks += 1; + if (requestChecks !== interruptAt) return false; + fiber.interruptUnsafe(); + return true; + }, + }; + + // Each candidate needs a distinct key because the broken case leaves + // its entry in the service's private in-flight map. The invalid window + // keeps the real scan synchronous once its detached fiber starts. + const input: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make(`2026-08-${String(interruptAt).padStart(2, "0")}`), + }; + const first = yield* service + .readSummary(input) + .pipe( + Effect.exit, + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + requestFiber = first; + yield* Effect.yieldNow; + dispatcher.flush(); + + const second = yield* service.readSummary(input).pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + yield* Effect.yieldNow; + dispatcher.flush(); + const secondExit = second.pollUnsafe(); + if (secondExit === undefined) { + second.interruptUnsafe(); + orphanedAt = interruptAt; + break; + } + if (Exit.isFailure(secondExit)) { + assert.fail("the matching request fiber was interrupted"); + } + assert.strictEqual(secondExit.value, "invalidWindow"); + } + + assert.isUndefined( + orphanedAt, + `interruption left the next matching request pending at scheduler check ${orphanedAt}`, + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 224662e9dca7..16a7478d954e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -7,7 +7,8 @@ * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm - * scans only reparse files that changed. + * scans only reparse files that changed, and a file that merely grew resumes + * from its cached parse position so only the appended bytes are read. * * @module UsageService */ @@ -26,6 +27,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -272,7 +274,14 @@ export const make = Effect.gen(function* () { ); }); - /** Parses one transcript, reusing the cached result when it is unchanged. */ + /** + * Parses one transcript, reusing the cached result when it is unchanged. + * + * A file that only grew re-parses from the cached position, so an actively + * written multi-hundred-megabyte rollout costs its appended bytes per scan + * rather than a full re-read. The reader verifies the position's guard bytes + * and silently restarts from byte 0 when they no longer match. + */ const readFileRecords = ( filePath: string, size: number, @@ -289,23 +298,85 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.records; + return cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords]; } - const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, provider, resumeFrom), + ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) return []; - // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. - const records = dedupeWithinFile(parsed); - fileCache.set(filePath, { size, mtimeMs, provider, records }); + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. One + // seen set spans the cached base, the new lines, and the tail so a + // resumed parse dedupes exactly like a full one. + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + + fileCache.set(filePath, { + size, + mtimeMs, + provider, + records, + tailRecords, + position: parsed.position, + }); cacheDirty = true; - return records; + return tailRecords.length === 0 ? records : [...records, ...tailRecords]; }); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + /** One provider directory's walk and parse, before rates are involved. */ + interface ScannedDir { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly volumeId: string; + /** Parsed records per file, or `null` when the directory does not exist. */ + readonly files: + | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] + | null; + } + + const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so the scan stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const scanned: ScannedDir[] = []; + for (const { provider, dir, fileName } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) { + scanned.push({ provider, dir, volumeId, files: null }); + continue; + } + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; + for (const file of files) { + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + parsedFiles.push({ path: file.path, records }); + } + scanned.push({ provider, dir, volumeId, files: parsedFiles }); + } + return scanned; + }); + + const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -338,13 +409,9 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - yield* ensureRates(); yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); - // The home resolvers ask for `Path` themselves; satisfy them from the - // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -355,6 +422,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { + concurrency: 2, + }); + const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -368,13 +442,8 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir, fileName } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - - if (!exists) { + for (const { provider, dir, volumeId, files } of scannedDirs) { + if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "missing", @@ -388,9 +457,6 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a @@ -399,13 +465,12 @@ export const make = Effect.gen(function* () { for (const file of files) { livePaths.add(file.path); - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - if (records.length === 0) { + if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; - for (const record of records) { + for (const record of file.records) { // Only sessions that contributed in-window count: the mtime slack // admits boundary files whose records fall outside the range. if (aggregator.add(record) && record.sessionId.length > 0) { @@ -459,6 +524,52 @@ export const make = Effect.gen(function* () { } satisfies UsageSummary; }); + /** + * In-flight scans by window, so concurrent identical requests (the usage + * page open on two clients at once) share one scan instead of racing over + * the same corpus twice. + */ + const inflightScans = new Map>(); + + const scanKey = (input: UsageSummaryInput): string => + JSON.stringify([ + input.timeZone, + input.sinceDay, + input.untilDay, + input.resolution ?? "day", + input.sinceTime ?? null, + input.untilTime ?? null, + ]); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const key = scanKey(input); + const deferred = yield* Effect.uninterruptible( + Effect.gen(function* () { + const existing = inflightScans.get(key); + if (existing !== undefined) return existing; + + // Enrollment and detached-fiber creation must be atomic. Otherwise a + // canceled first caller can leave a Deferred with no scan to finish it. + const created = Deferred.makeUnsafe(); + inflightScans.set(key, created); + // Detached so one departing client cannot tear the scan out from under + // the fibers awaiting it; a finished scan warms the cache either way. + yield* scanSummary(input).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Deferred.done(created, exit)), + ), + ), + Effect.forkDetach, + ); + return created; + }), + ); + // Waiting stays interruptible. The detached scan continues for other + // callers and still warms the cache if this caller leaves. + return yield* Deferred.await(deferred); + }); + return { readSummary } as const; }); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 8c6faa88a263..fdb0aabafa40 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -5,6 +5,7 @@ import { dedupeWithinFile, encodeScanCache, pruneScanCache, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -28,10 +29,27 @@ function record(overrides: Partial = {}): UsageRecord { }; } +function position(overrides: Partial = {}): CachedFile["position"] { + return { + resumeOffset: 120, + guardLength: 64, + guardHash: 0xdeadbeef, + codexState: null, + ...overrides, + }; +} + function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { const cache: ScanCache = new Map(); for (const [path, mtimeMs, records] of entries) { - cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + cache.set(path, { + size: records.length * 10, + mtimeMs, + provider: "claude", + records, + tailRecords: [], + position: position(), + }); } return cache; } @@ -49,14 +67,67 @@ describe("scan cache round trip", () => { records: [ record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), ], + tailRecords: [record({ provider: "grok", model: "grok-4.5-build", dedupeKey: null })], + position: position({ resumeOffset: 30, guardLength: 30, guardHash: 123 }), + }); + original.set("/codex.jsonl", { + size: 80, + mtimeMs: 400, + provider: "codex", + records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })], + tailRecords: [], + position: position({ + codexState: { + model: "gpt-5.2-codex", + sessionId: "session-c", + lastUsageSignature: '{"input_tokens":1}', + sawSessionMeta: true, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }, + }), }); const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); - expect(restored.size).toBe(3); + expect(restored.size).toBe(4); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); + expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); + }); + + it("drops an entry whose persisted parse state is corrupt", () => { + // Resuming with a bad reducer state would attach appended usage to the + // wrong model or replay fork-copied history; that entry must cold parse. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { ...encoded.files["/a.jsonl"]!, cs: { model: 42 } }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("drops an entry whose guard length is outside the supported range", () => { + // The guard length sizes a Buffer in the reader; a bogus value would make + // every parse of that file fail and silently drop its usage. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("rejects a document from the previous cache version", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const previous = { ...encoded, version: 2 }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); it("interns repeated model and session strings", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index bca97152c1d6..102058a07d35 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -19,17 +19,28 @@ import * as NodePath from "node:path"; import type { UsageProviderKind } from "@t3tools/contracts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; +import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. -export const USAGE_SCAN_CACHE_VERSION = 2 as const; +// v3: entries carry the parse position and reducer state so a grown file +// re-parses only its appended bytes instead of starting over. +export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; readonly mtimeMs: number; readonly provider: UsageProviderKind; + /** Records from newline-terminated lines, up to `position.resumeOffset`. */ readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer had not newline-terminated at + * parse time. Kept apart from `records` because an incremental parse + * re-reads that segment and would otherwise double count it. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; } export type ScanCache = Map; @@ -57,6 +68,14 @@ interface SerializedFile { readonly m: number; readonly p: UsageProviderKind; readonly r: readonly SerializedRecord[]; + /** Tail records; see `CachedFile.tailRecords`. */ + readonly t: readonly SerializedRecord[]; + /** Parse position: resume offset, guard length, guard hash. */ + readonly o: number; + readonly gl: number; + readonly gh: number; + /** Codex reducer state at `o`; `null` for stateless providers. */ + readonly cs: CodexScanState | null; } interface SerializedCache { @@ -82,24 +101,31 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { return next; }; + const serializeRecord = (record: UsageRecord): SerializedRecord => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]; + const files: Record = {}; for (const [path, entry] of cache) { files[path] = { s: entry.size, m: entry.mtimeMs, p: entry.provider, - r: entry.records.map((record) => [ - record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), - record.totals.uncachedInputTokens, - record.totals.cachedInputTokens, - record.totals.cacheCreationTokens, - record.totals.outputTokens, - record.totals.reasoningTokens, - record.dedupeKey, - record.reportedCostUsd, - ]), + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, }; } @@ -133,24 +159,16 @@ export function decodeScanCache(document: unknown): ScanCache { const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; - for (const [path, raw] of Object.entries(root.files)) { - if (typeof raw !== "object" || raw === null) continue; - const entry = raw as Partial; - if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; - if (!isRecordArray(entry.r)) continue; - - const provider: UsageProviderKind = entry.p; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + const decodeRecords = ( + rows: readonly unknown[], + provider: UsageProviderKind, + ): UsageRecord[] | null => { const records: UsageRecord[] = []; - // Any corrupt row disqualifies the whole entry. Keeping the survivors - // under the original (size, mtime) would read as a valid warm hit and the - // file would never be re-parsed, silently losing the dropped rows' usage. - let corrupt = false; - for (const row of entry.r) { - if (!isRecordArray(row) || row.length < 10) { - corrupt = true; - break; - } + for (const row of rows) { + if (!isRecordArray(row) || row.length < 10) return null; const [ timestampMs, modelIndex, @@ -175,8 +193,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(output) || !Number.isFinite(reasoning) ) { - corrupt = true; - break; + return null; } records.push({ @@ -195,14 +212,89 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } + return records; + }; - if (corrupt) continue; - cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; + // Position fields feed byte offsets and a Buffer allocation in the reader, + // so anything outside their real ranges must reject the entry: a bogus + // guard length would otherwise fail every parse of the file, silently + // dropping its usage instead of costing the documented cold re-parse. + if ( + typeof entry.o !== "number" || + !Number.isSafeInteger(entry.o) || + entry.o < 0 || + typeof entry.gl !== "number" || + !Number.isSafeInteger(entry.gl) || + entry.gl < 0 || + entry.gl > GUARD_LENGTH || + entry.gl > entry.o || + typeof entry.gh !== "number" || + !Number.isFinite(entry.gh) + ) { + continue; + } + const codexState = decodeCodexState(entry.cs); + if (codexState === undefined) continue; + + const provider: UsageProviderKind = entry.p; + const records = decodeRecords(entry.r, provider); + const tailRecords = decodeRecords(entry.t, provider); + if (records === null || tailRecords === null) continue; + + cache.set(path, { + size: entry.s, + mtimeMs: entry.m, + provider, + records, + tailRecords, + position: { + resumeOffset: entry.o, + guardLength: entry.gl, + guardHash: entry.gh, + codexState, + }, + }); } return cache; } +/** + * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt + * value, which disqualifies the entry: resuming with a bad state would attach + * appended usage to the wrong model or replay fork-copied history. + */ +function decodeCodexState(value: unknown): CodexScanState | null | undefined { + if (value === null) return null; + if (typeof value !== "object") return undefined; + const state = value as Partial; + if ( + typeof state.model !== "string" || + typeof state.sessionId !== "string" || + (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || + typeof state.sawSessionMeta !== "boolean" || + typeof state.suppressingForkCopies !== "boolean" || + typeof state.forkCopyAnchorMs !== "number" || + !Number.isFinite(state.forkCopyAnchorMs) + ) { + return undefined; + } + return { + model: state.model, + sessionId: state.sessionId, + lastUsageSignature: state.lastUsageSignature ?? null, + sawSessionMeta: state.sawSessionMeta, + suppressingForkCopies: state.suppressingForkCopies, + forkCopyAnchorMs: state.forkCopyAnchorMs, + }; +} + export interface PruneOptions { /** Files the walk just saw. Only meaningful inside the walked window. */ readonly livePaths: ReadonlySet; @@ -251,9 +343,17 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** Within-file de-duplication, applied before an entry is cached. */ -export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { - const seen = new Set(); +/** + * Within-file de-duplication, applied before an entry is cached. + * + * Callers stitching an incremental parse together pass one `seen` set across + * the line and tail record batches so the whole file stays deduplicated as a + * unit; the set is mutated in place. + */ +export function dedupeWithinFile( + records: readonly UsageRecord[], + seen: Set = new Set(), +): readonly UsageRecord[] { const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 000000000000..5feb68b2ff58 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,210 @@ +// @effect-diagnostics nodeBuiltinImport:off - resume coverage writes, appends +// to, and truncates real transcript files byte-exactly, mirroring the reader's +// own deliberate node:fs usage. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; + +import { readTranscriptRecords } from "./usageTranscriptReader.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); +}); + +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +function codexMetaLine(): string { + return `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T10:00:00Z", + payload: { type: "session_meta", id: "codex-session-1" }, + })}\n`; +} + +function codexModelLine(model: string): string { + return `${JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "turn_context", model }, + })}\n`; +} + +function codexUsageLine(outputTokens: number, secondsOffset: number): string { + return `${JSON.stringify({ + type: "event_msg", + timestamp: `2026-08-01T10:00:${String(secondsOffset).padStart(2, "0")}Z`, + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: outputTokens } }, + }, + })}\n`; +} + +describe("readTranscriptRecords resume", () => { + it("parses only appended lines when resuming a grown file", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 2); + assert.isFalse(first.resumed); + + await NodeFSP.appendFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.totals.outputTokens, 11); + + // The stitched result matches a from-scratch parse of the whole file. + const full = await readTranscriptRecords(path, "claude"); + assert.isNotNull(full); + assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); + }); + + it("carries the Codex reducer state across the resume boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex")); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 0); + + // The appended usage event has no turn_context or session_meta of its own; + // model and session must come from the state captured before the boundary. + await NodeFSP.appendFile(path, codexUsageLine(9, 5)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex"); + assert.strictEqual(second.records[0]?.sessionId, "codex-session-1"); + }); + + it("suppresses a Codex duplicate usage event that straddles the boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), + ); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + + // Codex re-emits an unchanged token_count on stream boundaries; the copy + // lands after the resume point and must still be dropped. + await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [21], + ); + }); + + it("defers an unterminated trailing line to tailRecords, then consumes it once terminated", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + const unterminated = claudeLine(2, 7).trimEnd(); + await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + assert.strictEqual(first.tailRecords.length, 1); + assert.strictEqual(first.tailRecords[0]?.totals.outputTokens, 7); + + // Completing the line and appending another re-reads from the resume + // point, so the once-tail record arrives exactly once as a line record. + await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [7, 11], + ); + assert.strictEqual(second.tailRecords.length, 0); + }); + + it("re-parses from the start when the guard bytes no longer match", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + // Same path, larger size, different content: a replaced file, not growth. + await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [13, 17], + ); + }); + + it("re-parses from the start when the file shrank below the resume point", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + await NodeFSP.writeFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [11], + ); + }); + + it("parses a line larger than one stream chunk", async () => { + // Tool-heavy transcripts carry multi-megabyte single lines; they arrive + // split across many chunks and must reassemble into one record. + const path = NodePath.join(dir, "claude.jsonl"); + const bigLine = `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_big", + sessionId: "session-1", + padding: "x".repeat(512 * 1024), + message: { + id: "msg_big", + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: 42 }, + }, + })}\n`; + await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); + + const parsed = await readTranscriptRecords(path, "claude"); + assert.isNotNull(parsed); + assert.deepStrictEqual( + parsed.records.map((record) => record.totals.outputTokens), + [42, 7], + ); + }); + + it("returns null for an unreadable file", async () => { + assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 33aef8fae25c..9e5ab6e0c9e0 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -4,16 +4,19 @@ * * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB - * across ~1,500 files, and `readline` over a read stream is roughly an order of + * across ~1,500 files, and buffer-level streaming is roughly an order of * magnitude cheaper than materialising each file. The equivalent Effect stream * pipeline is idiomatic but not fast enough to sit behind a page load. * + * Transcripts are append-only, so a parse also reports the byte position it + * stopped at. A later scan of the same file resumes from that position and + * parses only the appended bytes, which is what keeps a warm scan cheap while a + * session is actively writing a multi-hundred-megabyte rollout. + * * @module usageTranscriptReader */ -import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -23,6 +26,7 @@ import { parseClaudeLine, parseCodexLine, parseGrokLine, + type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -32,6 +36,56 @@ export interface TranscriptFile { readonly mtimeMs: number; } +/** + * Where a parse stopped, with enough state to continue from there. + * + * The guard hash fingerprints the bytes immediately before `resumeOffset`. A + * resume only proceeds when those bytes still match: transcripts are + * append-only by design, but a rotated or rewritten file silently mis-parsed + * from the middle would corrupt usage totals. The window is a cheap tripwire + * for those realistic failure shapes, all of which disturb the file's tail at + * that exact offset; it deliberately does not hash the whole prefix, which + * would cost the full re-read the resume exists to avoid. + */ +export interface TranscriptParsePosition { + /** Byte offset just past the last newline-terminated line consumed. */ + readonly resumeOffset: number; + /** Length of the fingerprinted window ending at `resumeOffset`. */ + readonly guardLength: number; + /** FNV-1a hash of that window. */ + readonly guardHash: number; + /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ + readonly codexState: CodexScanState | null; +} + +export interface TranscriptParseResult { + /** Records from newline-terminated lines at or after the parse start. */ + readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer has not newline-terminated yet. + * Kept out of `records` because `position` deliberately excludes that + * segment: the next scan re-reads it once the writer finishes the line. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; + /** Whether the parse continued from `resumeFrom` rather than byte 0. */ + readonly resumed: boolean; +} + +/** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ +export const GUARD_LENGTH = 64; +const NEWLINE = 0x0a; +const CARRIAGE_RETURN = 0x0d; + +function fnv1a(buffer: Buffer): number { + let hash = 0x811c9dc5; + for (let index = 0; index < buffer.length; index += 1) { + hash ^= buffer[index]!; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + /** * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. * @@ -100,6 +154,25 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +async function guardMatches( + handle: NodeFSP.FileHandle, + position: TranscriptParsePosition, +): Promise { + if (position.guardLength <= 0 || position.guardLength > GUARD_LENGTH) return false; + try { + const window = Buffer.alloc(position.guardLength); + const { bytesRead } = await handle.read( + window, + 0, + position.guardLength, + position.resumeOffset - position.guardLength, + ); + return bytesRead === position.guardLength && fnv1a(window) === position.guardHash; + } catch { + return false; + } +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. @@ -109,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise { * under the same `(size, mtime)` key would silently drop that file's usage * until the file next changes. * + * With `resumeFrom`, parsing continues from that position when its guard bytes + * still match, so only appended lines are read; otherwise the whole file is + * re-parsed from the start and `resumed` reports `false`. + * * Codex carries the active model on `turn_context` lines that hold no usage of * their own, so those still have to pass through the reducer to keep model * attribution correct. @@ -116,43 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, -): Promise { - const records: UsageRecord[] = []; - const codexState = initialCodexScanState(); + resumeFrom?: TranscriptParsePosition, +): Promise { + let handle: NodeFSP.FileHandle; + try { + handle = await NodeFSP.open(filePath, "r"); + } catch { + return null; + } try { - const lines = NodeReadline.createInterface({ - input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); + let codexState = initialCodexScanState(); + let resumed = false; + let start = 0; + if ( + resumeFrom !== undefined && + resumeFrom.resumeOffset > 0 && + (provider !== "codex" || resumeFrom.codexState !== null) && + (await guardMatches(handle, resumeFrom)) + ) { + if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; + start = resumeFrom.resumeOffset; + resumed = true; + } - for await (const line of lines) { + const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { if (provider === "codex") { if ( !mightCarryUsage(line, provider) && !line.includes('"turn_context"') && !line.includes('"session_meta"') ) { - continue; + return; } - const record = parseCodexLine(line, codexState); - if (record !== null) records.push(record); - continue; + const record = parseCodexLine(line, state); + if (record !== null) out.push(record); + return; } - + if (!mightCarryUsage(line, provider)) return; if (provider === "grok") { - if (!mightCarryUsage(line, provider)) continue; - for (const grokRecord of parseGrokLine(line)) records.push(grokRecord); + for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); + return; + } + const record = parseClaudeLine(line); + if (record !== null) out.push(record); + }; + + const toLineString = (lineBuffer: Buffer): string => { + const content = + lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN + ? lineBuffer.subarray(0, -1) + : lineBuffer; + return content.toString("utf8"); + }; + + const records: UsageRecord[] = []; + // Buffer-level line splitting rather than `readline`, because resuming + // needs byte-exact offsets and decoded strings cannot provide them. + // Newline-free chunks are collected rather than concatenated as they + // arrive, so a single huge line costs one copy instead of one per chunk. + let resumeOffset = start; + let pendingChunks: Buffer[] = []; + const stream = handle.createReadStream({ + start, + autoClose: false, + }) as AsyncIterable; + for await (const chunk of stream) { + if (!chunk.includes(NEWLINE)) { + pendingChunks.push(chunk); continue; } + const buffer: Buffer = + pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); + pendingChunks = []; + let lineStart = 0; + for (;;) { + const newlineIndex = buffer.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) break; + parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + lineStart = newlineIndex + 1; + } + resumeOffset += lineStart; + if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + } - if (!mightCarryUsage(line, provider)) continue; - const record = parseClaudeLine(line); - if (record !== null) records.push(record); + // A trailing segment without its newline is parsed for this result but not + // consumed: a writer may still be appending to it, and counting a half + // record now and its full form later would double count. + const tailRecords: UsageRecord[] = []; + if (pendingChunks.length > 0) { + const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); + if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); } + + const guardLength = Math.min(GUARD_LENGTH, resumeOffset); + let guardHash = 0; + if (guardLength > 0) { + const window = Buffer.alloc(guardLength); + await handle.read(window, 0, guardLength, resumeOffset - guardLength); + guardHash = fnv1a(window); + } + + return { + records, + tailRecords, + position: { + resumeOffset, + guardLength, + guardHash, + codexState: provider === "codex" ? codexState : null, + }, + resumed, + }; } catch { return null; + } finally { + await handle.close().catch(() => undefined); } - - return records; } From 7e4ce3bbb16c3cfa1ea756de917d8f9398e5999e Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 12:11:41 +0200 Subject: [PATCH 35/42] perf(server): cut chatty tool-update frames by 90% (#8368) --- .../ActivityPayloadProjection.ts | 7 +- .../ThreadLiveEventCoalescer.test.ts | 171 +++++++++++++ .../orchestration/ThreadLiveEventCoalescer.ts | 207 +++++++++++++++ apps/server/src/server.test.ts | 240 ++++++++++++++++++ apps/server/src/ws.ts | 23 +- 5 files changed, 633 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts create mode 100644 apps/server/src/orchestration/ThreadLiveEventCoalescer.ts diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 7d2ba79ce4ad..0b1cb15d3dbc 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -495,9 +495,6 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | * update within the turn — a later update belongs to a subsequent call that * reuses the same identity and is still in flight. Rows without a lifecycle * identity pass through, matching the clients, which never collapse them. - * Live `thread.activity-appended` events are untouched: updates still stream - * in real time and the completion supersedes them on the client as before. - * * Deliberate divergence from client collapse: clients fold only *adjacent* * lifecycle rows, so a superseded update separated from its completion by an * interleaved parallel call renders as its own row today, and this drop @@ -524,7 +521,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { continue; } - const key = `${activity.turnId ?? ""}${identity}`; + const key = `${activity.turnId ?? ""}\u0000${identity}`; const indices = completionIndicesByKey.get(key); if (indices) { indices.push(index); @@ -544,7 +541,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { return true; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`); + const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); return !indices?.some((completionIndex) => completionIndex > index); }); } diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts new file mode 100644 index 000000000000..0a9915294d03 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -0,0 +1,171 @@ +import { + EventId, + MessageId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, expect } from "vite-plus/test"; + +import { + coalesceLiveToolUpdatedEvents, + makeThreadLiveEventCoalescer, +} from "./ThreadLiveEventCoalescer.ts"; + +const threadId = ThreadId.make("thread-coalescer-test"); +const turnId = TurnId.make("turn-coalescer-test"); + +function makeToolActivity( + sequence: number, + options: { + readonly kind?: "tool.updated" | "tool.completed"; + readonly toolCallId?: string; + readonly turnId?: TurnId; + } = {}, +): OrchestrationEvent { + const { + kind = "tool.updated", + toolCallId = "call-edit", + turnId: activityTurnId = turnId, + } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: toolCallId ? { toolCallId } : {}, + }, + turnId: activityTurnId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId, activity }, + }; +} + +function makeMessage(sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make(`message-${sequence}`), + role: "assistant", + text: "Still working", + turnId, + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }; +} + +describe("ThreadLiveEventCoalescer", () => { + it("coalesces only calls with a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "call-a" }), + makeToolActivity(2, { toolCallId: "call-b" }), + makeToolActivity(3, { toolCallId: "call-a" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]); + }); + + it("preserves parallel same-label calls without a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "" }), + makeToolActivity(2, { toolCallId: "" }), + makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]); + }); + + it("does not coalesce stable tool calls across turns", () => { + const events = [ + makeToolActivity(1, { turnId: TurnId.make("turn-old") }), + makeToolActivity(2, { turnId: TurnId.make("turn-new") }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]); + }); + + it("flushes a stable update run before a completion boundary", () => { + const events = [ + makeToolActivity(1), + makeToolActivity(2), + makeToolActivity(3, { kind: "tool.completed" }), + makeToolActivity(4), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]); + }); + + it.effect("flushes pending tool updates as soon as an unrelated event arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.forEach( + Array.from({ length: 10 }, (_, index) => index + 2), + (sequence) => + coalescer.offerAndWait({ kind: "event", event: makeToolActivity(sequence) }), + { discard: true }, + ); + yield* coalescer.offerAndWait({ kind: "event", event: makeMessage(12) }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([11, 12]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(2) }); + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(3) }); + yield* coalescer.offerAndWait({ kind: "synchronized" }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([3, "synchronized"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts new file mode 100644 index 000000000000..8271f6a550fb --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -0,0 +1,207 @@ +import type { OrchestrationEvent, OrchestrationThreadStreamItem } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { projectActivityEvent } from "./ActivityPayloadProjection.ts"; + +const COALESCE_WINDOW = Duration.millis(50); +const MAX_PENDING_UPDATES = 512; + +export type ThreadLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + +function isToolUpdated(event: OrchestrationEvent): boolean { + return ( + event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated" + ); +} + +function asTrimmedString(value: unknown): string | null { + if (!Predicate.isString(value)) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function stableToolCallIdentity(event: OrchestrationEvent): string | null { + if (event.type !== "thread.activity-appended") { + return null; + } + const payload = event.payload.activity.payload; + if (!Predicate.isObject(payload)) { + return null; + } + const data = Predicate.isObject(payload.data) ? payload.data : null; + return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId); +} + +/** + * Retain only the latest in-flight update for each stable tool-call id in a + * live run. Anonymous calls pass through because labels are not unique when + * tools execute in parallel. Survivors remain in sequence order. + */ +export function coalesceLiveToolUpdatedEvents( + events: ReadonlyArray, +): ReadonlyArray { + const survivors: Array = []; + let pendingUpdates: Array = []; + + const flushUpdates = () => { + const seen = new Set(); + const latestUpdates: Array = []; + for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) { + const event = pendingUpdates[index]!; + const identity = stableToolCallIdentity(event); + const activity = + event.type === "thread.activity-appended" ? event.payload.activity : undefined; + const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null; + if (key && seen.has(key)) { + continue; + } + if (key) { + seen.add(key); + } + latestUpdates.push(event); + } + latestUpdates.reverse(); + survivors.push(...latestUpdates); + pendingUpdates = []; + }; + + for (const event of events) { + if (isToolUpdated(event)) { + pendingUpdates.push(event); + continue; + } + flushUpdates(); + survivors.push(event); + } + flushUpdates(); + return survivors; +} + +export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")( + function* (options?: { readonly coalesceWindow?: Duration.Input }) { + const output = yield* Queue.unbounded(); + const input = yield* Queue.unbounded<{ + readonly value: ThreadLiveInput; + readonly processed?: Deferred.Deferred; + }>(); + const mutex = yield* Semaphore.make(1); + const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; + let pendingUpdates: Array = []; + let windowGeneration = 0; + let windowFiber: Fiber.Fiber | null = null; + + const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { + const fiber = windowFiber; + if (!fiber) { + return; + } + windowFiber = null; + yield* Fiber.interrupt(fiber); + }); + + const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* ( + boundary?: OrchestrationEvent, + ) { + const events = boundary ? [...pendingUpdates, boundary] : pendingUpdates; + pendingUpdates = []; + if (events.length === 0) { + return; + } + yield* Queue.offerAll( + output, + coalesceLiveToolUpdatedEvents(events).map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), + ); + }); + + const flushWindow = (generation: number) => + Effect.sleep(coalesceWindow).pipe( + Effect.andThen( + mutex.withPermits(1)( + Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), + ), + ), + Effect.ensuring( + Effect.sync(() => { + if (generation === windowGeneration) { + windowFiber = null; + } + }), + ), + ); + + const process = Effect.fn("ThreadLiveEventCoalescer.process")(function* ( + input: ThreadLiveInput, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + if (input.kind === "event" && isToolUpdated(input.event)) { + pendingUpdates.push(input.event); + if (pendingUpdates.length === 1) { + const generation = ++windowGeneration; + windowFiber = yield* Effect.forkScoped(flushWindow(generation)); + } + if (pendingUpdates.length >= MAX_PENDING_UPDATES) { + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); + } + return; + } + + yield* cancelWindow(); + windowGeneration += 1; + // A non-update event closes the run immediately. The coalescer keeps + // that boundary after the final update from the run. + if (input.kind === "event") { + yield* flushPending(input.event); + } else { + yield* flushPending(); + yield* Queue.offer(output, { kind: "synchronized" }); + } + }), + ); + }); + + yield* Stream.fromQueue(input).pipe( + Stream.runForEach(({ value, processed }) => + process(value).pipe( + Effect.andThen(processed ? Deferred.succeed(processed, undefined) : Effect.void), + ), + ), + Effect.forkScoped, + ); + + const offer = (value: ThreadLiveInput) => Queue.offer(input, { value }).pipe(Effect.asVoid); + + // Synchronization callers wait for their marker to pass through the same + // ordered input queue before draining output produced ahead of it. + const offerAndWait = Effect.fn("ThreadLiveEventCoalescer.offerAndWait")(function* ( + value: ThreadLiveInput, + ) { + const processed = yield* Deferred.make(); + yield* Queue.offer(input, { value, processed }); + yield* Deferred.await(processed); + }); + + return { + offer, + offerAndWait, + stream: Stream.fromQueue(output), + takeAll: Queue.takeAll(output), + } as const; + }, +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0780af4d1b4a..93a3977ddd0f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -19,6 +19,7 @@ import { ExternalLauncherCommandNotFoundError, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, + type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -30,6 +31,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TurnId, WS_METHODS, WsRpcGroup, EditorId, @@ -197,6 +199,44 @@ const defaultModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; + +const makeLiveToolActivityEvent = ( + sequence: number, + kind: "tool.updated" | "tool.completed" = "tool.updated", + options: { + readonly toolCallId?: string; + readonly title?: string; + readonly path?: string; + } = {}, +): Extract => { + const { toolCallId = "call-edit", title = "Editing app.ts", path = "src/app.ts" } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: title, + payload: { + itemType: "file_change", + title, + data: { toolCallId, path }, + }, + turnId: TurnId.make("turn-edit"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-tool-${sequence}`), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId: defaultThreadId, activity }, + }; +}; const testEnvironmentDescriptor = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -6918,6 +6958,206 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("coalesces buffered live tool updates to the latest state", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + makeLiveToolActivityEvent(3), + makeLiveToolActivityEvent(4), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "event"); + assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 4); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes more than one tool chunk before the synchronization marker", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + ...Array.from({ length: 512 }, (_, index) => + makeLiveToolActivityEvent(index + 2), + ), + makeLiveToolActivityEvent(514, "tool.updated", { + toolCallId: "call-read", + title: "Reading server.test.ts", + path: "apps/server/src/server.test.ts", + }), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items.slice(1, 3).map((item) => { + assert.equal(item?.kind, "event"); + if (item?.kind !== "event" || item.event.type !== "thread.activity-appended") { + return null; + } + return { + sequence: item.event.sequence, + summary: item.event.payload.activity.summary, + payload: item.event.payload.activity.payload, + }; + }), + [ + { + sequence: 513, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: { + files: [{ path: "src/app.ts" }], + toolCallId: "call-edit", + }, + }, + }, + { + sequence: 514, + summary: "Reading server.test.ts", + payload: { + itemType: "file_change", + title: "Reading server.test.ts", + data: { + files: [{ path: "apps/server/src/server.test.ts" }], + toolCallId: "call-read", + }, + }, + }, + ], + ); + assert.deepEqual(items[3], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes a tool update before an interleaved message", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + const messageEvent = { + sequence: 3, + eventId: EventId.make("event-interleaved-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-interleaved"), + role: "assistant", + text: "Still working", + turnId: TurnId.make("turn-edit"), + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + messageEvent, + makeLiveToolActivityEvent(4, "tool.completed"), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items + .slice(1) + .map((item) => (item.kind === "event" ? [item.event.sequence, item.event.type] : null)), + [ + [2, "thread.activity-appended"], + [3, "thread.message-sent"], + [4, "thread.activity-appended"], + ], + ); + assert.equal( + items[3]?.kind === "event" && items[3].event.type === "thread.activity-appended" + ? items[3].event.payload.activity.kind + : null, + "tool.completed", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { let readEventsCalls = 0; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9983de224f29..4da8420799e9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -80,6 +80,7 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; +import { makeThreadLiveEventCoalescer } from "./orchestration/ThreadLiveEventCoalescer.ts"; import { cleanupFailedUploadedAttachments, normalizeDispatchCommand, @@ -1501,17 +1502,15 @@ const makeWsRpcLayer = ( Stream.filter(isThisThreadDetailEvent), Stream.map((event) => ({ kind: "event" as const, - event: projectActivityEvent(event), + event, })), ); // Attach live delivery before reading either replay or snapshot state. // Otherwise an event published while the snapshot is loading is lost. - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const bufferedLiveStream = Stream.fromQueue(liveBuffer); + const liveBuffer = yield* makeThreadLiveEventCoalescer(); + yield* Effect.forkScoped(liveStream.pipe(Stream.runForEach(liveBuffer.offer))); + const bufferedLiveStream = liveBuffer.stream; // When the client already loaded the snapshot over HTTP it passes // that snapshot's sequence, and we resume the live subscription by @@ -1560,8 +1559,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; @@ -1602,8 +1603,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; From f32f9a2f41342bf8a1a109d6ebe9b70044c5311b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 03:24:24 -0700 Subject: [PATCH 36/42] fix(server): settle threads server-side (#8600) --- .../settings/DesktopClientSettings.test.ts | 2 - apps/mobile/src/features/home/HomeScreen.tsx | 47 +- .../src/features/home/useThreadListActions.ts | 12 +- .../features/settings/SettingsRouteScreen.tsx | 53 +- .../threads/ThreadNavigationSidebar.tsx | 51 +- .../features/threads/thread-list-v2-items.tsx | 26 +- .../src/features/threads/threadListV2.test.ts | 161 +---- .../src/features/threads/threadListV2.ts | 81 +-- .../src/persistence/mobile-preferences.ts | 5 - .../OrchestrationEngineHarness.integration.ts | 7 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/git/GitManager.test.ts | 425 +++++++++++- apps/server/src/git/GitManager.ts | 197 +++++- apps/server/src/orchestration/Errors.ts | 21 +- .../Layers/OrchestrationEngine.test.ts | 216 +++++- .../Layers/OrchestrationEngine.ts | 32 +- .../Layers/OrchestrationReactor.test.ts | 13 +- .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProviderCommandReactor.test.ts | 45 ++ .../Layers/ProviderCommandReactor.ts | 24 +- .../ThreadSettlementPolicy.test.ts | 159 +++++ .../orchestration/ThreadSettlementPolicy.ts | 108 +++ .../ThreadSettlementReactor.test.ts | 640 ++++++++++++++++++ .../orchestration/ThreadSettlementReactor.ts | 185 +++++ .../src/orchestration/decider.settled.test.ts | 51 +- apps/server/src/orchestration/decider.ts | 125 ++-- .../Layers/OrchestrationEventStore.ts | 23 +- .../Services/OrchestrationEventStore.ts | 5 +- apps/server/src/server.test.ts | 61 +- apps/server/src/server.ts | 21 +- apps/server/src/serverSettings.test.ts | 28 + apps/server/src/ws.ts | 67 +- apps/web/src/components/ChatView.tsx | 61 +- apps/web/src/components/Sidebar.logic.ts | 7 +- apps/web/src/components/Sidebar.tsx | 55 +- .../components/ThreadStatusIndicators.test.ts | 36 +- apps/web/src/components/chat/ChatHeader.tsx | 5 - .../components/settings/SettingsPanels.tsx | 133 ++-- .../settings/settingsSearch.test.ts | 23 +- .../src/components/settings/settingsSearch.ts | 8 +- .../useAvailableSettingsSearchItems.ts | 14 +- apps/web/src/hooks/useNowMinute.ts | 7 +- apps/web/src/hooks/useSettings.test.ts | 18 + apps/web/src/hooks/useSettings.ts | 4 +- apps/web/src/hooks/useThreadActionMenu.ts | 28 +- apps/web/src/hooks/useThreadActions.ts | 27 +- docs/internals/overview.md | 18 +- docs/user/thread-sidebar.md | 11 + .../src/state/threadSettled.test.ts | 587 ---------------- .../client-runtime/src/state/threadSettled.ts | 193 +----- .../src/state/threadSnoozed.test.ts | 60 ++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.ts | 8 + packages/contracts/src/settings.test.ts | 39 +- packages/contracts/src/settings.ts | 12 +- 55 files changed, 2593 insertions(+), 1658 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadSettlementPolicy.test.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementPolicy.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementReactor.test.ts create mode 100644 apps/server/src/orchestration/ThreadSettlementReactor.ts delete mode 100644 packages/client-runtime/src/state/threadSettled.test.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 736311eb464b..27981a8b9828 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -40,8 +40,6 @@ const clientSettings: ClientSettings = { planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, - sidebarAutoSettleAfterDays: 3, - sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index b738ff6dd12f..34f4f4057a5d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -53,7 +53,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; @@ -209,9 +208,6 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -487,33 +483,6 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule, matching web. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onSettleThread(thread); @@ -580,9 +549,7 @@ export function HomeScreen(props: HomeScreenProps) { toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now is quantized to the minute and ticks so the inactivity auto-settle - // boundary is actually crossed while the app stays open (mirrors web); - // without a clock dependency the partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -591,8 +558,7 @@ export function HomeScreen(props: HomeScreenProps) { useFocusEffect( useCallback(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable or focus: the previous value can be hours - // old and misclassify the inactivity auto-settle boundary until the first tick. + // Refresh immediately on enable or focus because the previous value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -679,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -864,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -874,7 +834,6 @@ export function HomeScreen(props: HomeScreenProps) { ); }, [ - handleChangeRequestState, handleDeleteThread, arrangedPinnedKeys, handleMovePinnedThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 5c66944042ad..dae6c46a89dd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -118,16 +118,6 @@ function useThreadActionExecutor( ); return false; } - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { - Alert.alert( - actionFailureTitle(action), - "This thread still needs attention. Resolve or interrupt it first, then try again.", - ); - return false; - } // Archive keeps its original, narrower guard: never interrupt a // thread mid-turn. if ( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index e781b97de99c..81f5d4e986cf 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -34,6 +34,9 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -527,26 +530,54 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; + const { savedConnectionsById } = useSavedRemoteConnections(); + const connections = Object.values(savedConnectionsById).sort((left, right) => + left.environmentLabel.localeCompare(right.environmentLabel), + ); return ( - savePreferences({ autoSettleOnMerge: value })} - /> + {connections.map((connection) => ( + + ))} ); } +function EnvironmentAutoSettleSwitch(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "auto-settle settings update", + reportFailure: true, + }); + if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { + return null; + } + return ( + { + void updateSettings({ + environmentId: props.environmentId, + input: { patch: { sidebarAutoSettleOnMerge: value } }, + }); + }} + /> + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 512b9b78a4ca..4a4d36c7a211 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,7 +9,6 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -28,7 +27,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; @@ -82,7 +80,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -164,10 +161,6 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -365,33 +358,6 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( @@ -414,9 +380,7 @@ function ThreadNavigationSidebarPane( toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now ticks per minute so the inactivity auto-settle boundary is actually - // crossed while the pane stays open; without a clock dependency the - // partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -424,9 +388,7 @@ function ThreadNavigationSidebarPane( const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -509,20 +471,15 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -931,7 +888,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1058,7 +1014,6 @@ function ThreadNavigationSidebarPane( arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, - handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 51b1ed7afcdb..5ea43000bf1b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { - resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, - type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -370,12 +368,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR (state + last activity) for the partition's - merge and close rules. Mirrors web's onChangeRequestState. */ - readonly onChangeRequestState?: ( - threadKey: string, - changeRequest: ThreadListV2ChangeRequestState | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -398,24 +390,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onMovePinnedThread, - onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); - const prState = pr?.state ?? null; - const prUpdatedAt = pr?.updatedAt ?? null; - const threadKey = `${thread.environmentId}:${thread.id}`; - useEffect(() => { - const changeRequest = resolveThreadListV2ChangeRequestState({ - linkedPullRequest: thread.linkedPullRequest, - state: prState, - updatedAt: prUpdatedAt, - }); - if (changeRequest === undefined) return; - onChangeRequestState?.(threadKey, changeRequest); - }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; @@ -453,9 +432,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - // Swipe: the v2 primary action is the lifecycle transition. Every settled - // row can un-settle — explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. + // Swipe: the v2 primary action is the lifecycle transition. Un-settling a + // settled row keeps it active until new activity clears the user override. const canUnsettle = variant === "slim"; const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); const snoozeGateExpiryMs = props.snoozeSupported diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 24c07eae6da1..48edf3906002 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,7 +16,6 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, - resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -61,42 +60,6 @@ const linkedPullRequest = { url: "https://github.com/pingdotgg/t3code/pull/42", }; -describe("resolveThreadListV2ChangeRequestState", () => { - it("preserves the previous state while a linked pull request reloads", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: null, - updatedAt: null, - }), - ).toBeUndefined(); - }); - - it("clears the previous state after a pull request is unlinked", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest: null, - state: null, - updatedAt: null, - }), - ).toBeNull(); - }); - - it("reports a loaded linked pull request", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - ).toEqual({ - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }); - }); -}); - describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); @@ -319,51 +282,18 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("ignores the previous pull request state after a different pull request is linked", () => { - const thread = makeThread({ - id: ThreadId.make("linked"), - title: "Linked pull request", - linkedPullRequest, - }); - const layout = buildThreadListV2Items({ - threads: [thread], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', - }, - ], - ]), - now: NOW, - }); - - expect(layout.settledCount).toBe(0); - expect(layout.items[0]?.variant).toBe("card"); - }); - - it("settles a thread only when the cached pull request identity matches", () => { + it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", linkedPullRequest, + settledOverride: "settled", + settledAt: NOW, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }, - ], - ]), now: NOW, }); @@ -371,23 +301,6 @@ describe("buildThreadListV2Items", () => { expect(layout.items[0]?.variant).toBe("slim"); }); - it("keeps a merged thread active when auto-settle on merge is off", () => { - const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); - const layout = buildThreadListV2Items({ - threads: [merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([ - [`${environmentId}:${merged.id}`, { state: "merged" as const }], - ]), - autoSettleOnMerge: false, - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); - expect(layout.settledCount).toBe(0); - }); - it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ @@ -439,73 +352,21 @@ describe("buildThreadListV2Items", () => { expect(layout.settledCount).toBe(1); }); - it("moves pinned threads to the settled shelf when their pull request merges", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", - pinnedAt: "2026-06-01T12:00:00.000Z", - }); - const layout = buildThreadListV2Items({ - threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); - expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); - expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); - expect(layout.settledCount).toBe(1); - }); - - it("moves inactive pinned threads to the settled shelf", () => { - const inactive = makeThread({ - id: ThreadId.make("pinned-inactive"), - title: "Pinned inactive thread", - createdAt: "2026-05-20T00:00:00.000Z", - pinnedAt: "2026-05-21T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-inactive"), - state: "completed", - requestedAt: "2026-05-21T00:00:00.000Z", - startedAt: "2026-05-21T00:00:01.000Z", - completedAt: "2026-05-21T00:00:02.000Z", - assistantMessageId: null, - }, - }); - const layout = buildThreadListV2Items({ - threads: [inactive], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-inactive" }, - variant: "slim", - pinned: false, - }); - expect(layout.settledCount).toBe(1); - }); - - it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", + it("keeps active pinned threads in the pinned block", () => { + const pinned = makeThread({ + id: ThreadId.make("pinned"), + title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ - threads: [merged], + threads: [pinned], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - autoSettleOnMerge: false, now: NOW, }); expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-merged" }, + thread: { id: "pinned" }, variant: "card", pinned: true, }); @@ -560,9 +421,7 @@ describe("buildThreadListV2Items", () => { ], environmentId: null, searchQuery: "", - // Minute-floored partition clock vs precise snooze clock. - now: "2026-06-02T00:01:00.000Z", - snoozeNow: "2026-06-02T00:01:07.500Z", + now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index be3343a21bad..cf284b41605a 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,22 +1,18 @@ import { - effectiveSettled, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; -import type { - ChangeRequestSettleSource, - SnoozePreset, -} from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { activeThreadAnchorTimestampMs, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -33,35 +29,6 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; -export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { - readonly linkedPullRequestKey?: string | null; -} - -function linkedPullRequestKey( - linkedPullRequest: ThreadLinkedPullRequest | null | undefined, -): string | null { - if (linkedPullRequest == null) return null; - return JSON.stringify([ - linkedPullRequest.projectId, - linkedPullRequest.repository.toLowerCase(), - linkedPullRequest.number, - ]); -} - -/** Keep the previous linked PR state while its detail query reloads. */ -export function resolveThreadListV2ChangeRequestState(input: { - readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; - readonly state: ChangeRequestSettleSource["state"] | null; - readonly updatedAt: string | null; -}): ThreadListV2ChangeRequestState | null | undefined { - if (input.state === null) return input.linkedPullRequest == null ? null : undefined; - return { - state: input.state, - updatedAt: input.updatedAt, - linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), - }; -} - export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -347,8 +314,7 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. Mobile stores these - * auto-settle preferences per device. + * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -359,8 +325,6 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -368,17 +332,10 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; - readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; - /** Injectable for tests; defaults to now. */ - readonly now?: string; - /** Second-precise clock for snooze classification. Callers pass a - minute-quantized `now` for memoization; snooze wake times are - second-precise, so classifying with the floored minute would hold a - woken thread hidden for up to a minute. Defaults to `now`. */ - readonly snoozeNow?: string; + /** Second-precise clock used for time-based classification. */ + readonly now: string; /** Expands the snoozed shelf into rows. Collapsed is the default. */ readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ @@ -387,10 +344,7 @@ export function buildThreadListV2Items(input: { a split-view detail can never lose its navigation row. */ readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { - const now = input.now ?? new Date().toISOString(); - const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; - const autoSettleOnMerge = input.autoSettleOnMerge ?? true; + const now = input.now; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -402,8 +356,7 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { - // Callers pass live (unarchived) shells; settled threads are among them - // and partition into the tail via effectiveSettled. + // Callers pass live shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; @@ -422,16 +375,8 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const cachedChangeRequest = - input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - const changeRequest = - cachedChangeRequest !== null && - (cachedChangeRequest.linkedPullRequestKey ?? null) === - linkedPullRequestKey(thread.linkedPullRequest) - ? cachedChangeRequest - : null; // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); if ( thread.snoozedUntil != null && @@ -442,15 +387,7 @@ export function buildThreadListV2Items(input: { } continue; } - if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 5d0bd8a3c9dc..cf4c29c6041c 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -31,7 +31,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; @@ -167,9 +165,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.autoSettleOnMerge === "boolean") { - preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; - } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6ade6025bcbc..c43486623c4b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -64,6 +64,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -376,6 +377,12 @@ export const makeOrchestrationIntegrationHarness = ( drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 11d4078de60e..0de59db78160 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -209,6 +209,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, + threadAutoSettlement: true, threadSnooze: true, environmentThemes: true, threadPinning: true, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 64b5d428ab11..fc2a2c81279d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -620,6 +620,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + gitConfigReads?: string[]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -629,11 +630,30 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); - const vcsDriverLayer = GitVcsDriver.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), - ); + const vcsDriverLayer = input?.gitConfigReads + ? Layer.effect( + GitVcsDriver.GitVcsDriver, + GitVcsDriver.make.pipe( + Effect.map((service) => + GitVcsDriver.GitVcsDriver.of({ + ...service, + readConfigValue: (cwd, key) => + Effect.sync(() => input.gitConfigReads?.push(key)).pipe( + Effect.andThen(service.readConfigValue(cwd, key)), + ), + }), + ), + ), + ).pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ) + : GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, GitHubSourceControlProvider.make.pipe( @@ -955,6 +975,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("a warm PR cache does not reread repository identity for status", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); + + const gitConfigReads: string[] = []; + const { manager } = yield* makeManager({ gitConfigReads }); + + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + gitConfigReads.length = 0; + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + + const identityReads = gitConfigReads.filter( + (key) => + key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", + ); + expect(identityReads).toHaveLength(0); + }), + ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -974,6 +1018,377 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup returns null when the repository has no remotes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const { manager, ghCalls } = yield* makeManager(); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRefName: "main", + headRefName: "feature/saved-branch", + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); + + expect(pullRequest).toEqual({ + state: "open", + updatedAt: "2026-04-03T15:00:00.000Z", + }); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + }), + ); + + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "develop"]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + + const { manager } = yield* makeManager({ + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "Merged main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "develop", + headRefName: "main", + state: "MERGED", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-08T15:00:00.000Z", + }); + }), + ); + + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/main", + "feature/deleted-local-branch/child", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 217, + title: "Deleted local branch PR", + url: "https://github.com/pingdotgg/t3code/pull/217", + baseRefName: "main", + headRefName: "feature/deleted-local-branch", + state: "MERGED", + updatedAt: "2026-04-04T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-local-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-04T15:00:00.000Z", + }); + expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( + true, + ); + }), + ); + + it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/deleted-fork-branch": JSON.stringify([ + { + number: 218, + title: "Deleted fork branch PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/218", + baseRefName: "main", + headRefName: "feature/deleted-fork-branch", + state: "MERGED", + updatedAt: "2026-04-05T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-fork-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-05T15:00:00.000Z", + }); + expect( + ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ).toBe(true); + }), + ); + + it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); + const { manager, ghCalls } = yield* makeManager(); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", + }); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:old-owner/old-repository.git", + originalRemoteDir, + ); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 219, + title: "Old repository PR", + url: "https://github.com/old-owner/old-repository/pull/219", + baseRefName: "main", + headRefName: "feature/repointed-lookup", + state: "MERGED", + updatedAt: "2026-04-06T15:00:00Z", + }, + ]), + "[]", + ], + }, + }); + + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + expect(first?.state).toBe("merged"); + + const replacementRemoteDir = yield* createBareRemote(); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:new-owner/new-repository.git", + replacementRemoteDir, + ); + + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + + expect(second).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup shares the status cache for the same repository identity", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 220, + title: "Shared cache PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/220", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "MERGED", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/shared-pr-cache", + }); + + expect(status.pr?.state).toBe("merged"); + expect(pullRequest?.state).toBe("merged"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect("branch PR lookup propagates provider failures", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), + }), + }, + }); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SourceControlProviderError"); + }), + ); + it.effect("status finds a merged PR after its remote branch was deleted", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4fb9f321206e..393a8fd05592 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -90,6 +90,14 @@ export class GitManager extends Context.Service< input: VcsStatusInput, options?: GitVcsDriver.GitRemoteStatusOptions, ) => Effect.Effect; + /** Resolve the PR for a saved branch without changing the current checkout. */ + readonly branchPullRequest: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect< + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + GitManagerServiceError + >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -181,6 +189,7 @@ interface BranchHeadContext { preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; + targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -938,15 +947,16 @@ export const make = Effect.gen(function* () { prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); }), ); - // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the - // segments can contain a NUL byte, and refs are never empty, so "" decodes - // back to a null ref. + // Cache keys are NUL-joined. Automatic settlement validates repository URLs + // against the cached value before it uses a pull request decision. const prLookupCacheKey = ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; + localBranchExists?: boolean; + remoteName?: string | null; }, ) => [ @@ -954,6 +964,8 @@ export const make = Effect.gen(function* () { details.branch, details.upstreamRef ?? "", details.defaultBranch ?? "", + details.localBranchExists === false ? "0" : "1", + details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); // Consecutive failures per cache key, so a branch that keeps failing waits @@ -975,11 +987,20 @@ export const make = Effect.gen(function* () { }; const prLookupCache = yield* Cache.makeWith( (key: string) => { - const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000"); + const [ + cwd = "", + branch = "", + upstreamRef = "", + defaultBranch = "", + branchExists = "1", + remoteName = "", + ] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, + localBranchExists: branchExists !== "0", + ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { const headContext = yield* resolveBranchHeadContext(cwd, details); @@ -1000,7 +1021,11 @@ export const make = Effect.gen(function* () { } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. - if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + if ( + details.localBranchExists && + details.upstreamRef === null && + (yield* isUnpublishedBranch(cwd, headContext)) + ) { return { latest: null, headContext }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); @@ -1218,11 +1243,33 @@ export const make = Effect.gen(function* () { }; }); + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* (cwd: string, branch: string, remoteNameOverride?: string) { + const remoteName = + remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); + const [headRemote, targetRemote] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + return { + remoteName, + headRemoteUrlKey: + headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), + targetRemoteUrlKey: targetRemote.remoteUrlKey, + }; + }, + ); + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const remoteName = + details.remoteName ?? + (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; @@ -1286,6 +1333,7 @@ export const make = Effect.gen(function* () { headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -1869,6 +1917,140 @@ export const make = Effect.gen(function* () { }); return mergeGitStatusParts(local, remote); }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const remotes = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }); + const remoteNames = remotes.stdout + .split("\n") + .map((remoteName) => remoteName.trim()) + .filter((remoteName) => remoteName.length > 0); + const [firstRemoteName] = remoteNames; + if (firstRemoteName === undefined) return null; + const branchRef = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }); + const expectedRefName = `refs/heads/${branch}`; + const exactBranch = branchRef.stdout + .split("\n") + .find((line) => line.split("\u0000", 1)[0] === expectedRefName); + const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = + exactBranch?.split("\u0000") ?? []; + const localBranchExists = refName.length > 0; + let upstreamRef: string | null = null; + let remoteName: string | null = null; + if (savedUpstream.length > 0) { + if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Saved upstream for ${branch} is incomplete.`, + }); + } + remoteName = savedRemoteName; + const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); + upstreamRef = `${remoteName}/${upstreamBranch}`; + } else if (!localBranchExists) { + const trackingRefs = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }); + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + const matchingRemoteNames = remoteNames.filter((candidate) => + refNames.has(`refs/remotes/${candidate}/${branch}`), + ); + if (matchingRemoteNames.length > 1) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, + }); + } + remoteName = matchingRemoteNames[0] ?? null; + if (remoteName !== null) { + upstreamRef = `${remoteName}/${branch}`; + } + } + const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; + const defaultBranch = yield* gitCore + .resolveDefaultBranchName(cacheCwd, defaultRemoteName) + .pipe(Effect.orElseSucceed(() => null)); + const cacheKey = prLookupCacheKey(cacheCwd, { + branch, + upstreamRef, + defaultBranch, + localBranchExists, + ...(localBranchExists ? {} : { remoteName }), + }); + let cached = yield* Cache.get(prLookupCache, cacheKey); + const currentIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + !( + (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || + (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) + ); + const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + headContext.headRemoteUrlKey === identity.headRemoteUrlKey && + headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; + if (!canVerifyIdentity(cached.headContext, currentIdentity)) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} could not be verified.`, + }); + } + if (!hasSameIdentity(cached.headContext, currentIdentity)) { + yield* Cache.invalidate(prLookupCache, cacheKey); + cached = yield* Cache.get(prLookupCache, cacheKey); + const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + if ( + !canVerifyIdentity(cached.headContext, refreshedIdentity) || + !hasSameIdentity(cached.headContext, refreshedIdentity) + ) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} changed during pull request lookup.`, + }); + } + } + const { latest } = cached; + if (latest === null) return null; + if ( + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))) && + latest.state !== "open" + ) { + return null; + } + const statusPr = toStatusPr(latest); + return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -2416,6 +2598,7 @@ export const make = Effect.gen(function* () { localStatus, remoteStatus, status, + branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 7abd567704f1..dc29dcbfa6f8 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,3 +1,4 @@ +import { ThreadId } from "@t3tools/contracts"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Schema from "effect/Schema"; @@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< } } +export class OrchestrationThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "OrchestrationThreadSettleBlockedError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + +export const OrchestrationCommandRejection = Schema.Union([ + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, +]); +export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type; +export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection); + export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass()( "OrchestrationCommandPreviouslyRejectedError", { @@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< export type OrchestrationDispatchError = | ProjectionRepositoryError - | OrchestrationCommandInvariantError + | OrchestrationCommandRejection | OrchestrationCommandIdConflictError | OrchestrationCommandPreviouslyRejectedError | OrchestrationProjectorDecodeError diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ba23d56b5e07..3952cf34bddb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -10,6 +10,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -17,10 +18,12 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { @@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -async function createOrchestrationSystem() { +function makeOrchestrationLayer() { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-", }); - const orchestrationLayer = Layer.mergeAll( + return Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), ), OrchestrationProjectionSnapshotQueryLive, ).pipe( - Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(orchestrationLayer); +} + +async function createOrchestrationSystem() { + const runtime = ManagedRuntime.make(makeOrchestrationLayer()); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { @@ -218,6 +224,7 @@ describe("OrchestrationEngine", () => { } satisfies OrchestrationProjectionPipelineShape), ), Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -243,6 +250,205 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); + effectIt.effect("preserves the blocked-settle error and persists its rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const receipts = yield* OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository; + const projectId = ProjectId.make("project-blocked-settle"); + const threadId = ThreadId.make("thread-blocked-settle"); + const commandId = CommandId.make("cmd-blocked-settle"); + const createdAt = now(); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-blocked-settle-project-create"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-blocked-settle", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-blocked-settle-thread-create"), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-blocked-settle-session-set"), + threadId, + createdAt, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }); + + const sequence = yield* engine.latestSequence; + const error = yield* engine + .dispatch({ type: "thread.settle", commandId, threadId }) + .pipe(Effect.flip); + const message = + "This thread still needs attention. Resolve or interrupt it first, then try again."; + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId, + message, + }); + expect(Option.getOrNull(yield* receipts.getByCommandId({ commandId }))).toMatchObject({ + commandId, + aggregateKind: "thread", + aggregateId: threadId, + status: "rejected", + error: message, + resultSequence: sequence, + }); + expect(yield* engine.latestSequence).toBe(sequence); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + + effectIt.effect( + "rejects persisted changes and live background work without blocking unrelated threads", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(now())); + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const backgroundLiveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; + const projectId = ProjectId.make("project-auto-settle-guard"); + const guardedThreadId = ThreadId.make("thread-auto-settle-guarded"); + const unrelatedThreadId = ThreadId.make("thread-auto-settle-unrelated"); + const liveThreadId = ThreadId.make("thread-auto-settle-live"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-auto-settle-guard-project"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-auto-settle-guard", + createdAt: now(), + }); + for (const threadId of [guardedThreadId, unrelatedThreadId, liveThreadId]) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-create-${threadId}`), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now(), + }); + } + + const beforeUpdate = yield* snapshots.getSnapshot(); + const snapshotSequence = beforeUpdate.snapshotSequence; + const originalUpdatedAt = beforeUpdate.threads.find( + (thread) => thread.id === guardedThreadId, + )?.updatedAt; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-guard-meta"), + threadId: guardedThreadId, + branch: "new-branch", + }); + const afterUpdate = yield* snapshots.getSnapshot(); + expect(afterUpdate.threads.find((thread) => thread.id === guardedThreadId)?.updatedAt).toBe( + originalUpdatedAt, + ); + + const staleError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-stale-snapshot"), + threadId: guardedThreadId, + snapshotSequence, + }) + .pipe(Effect.flip); + expect(staleError._tag).toBe("OrchestrationCommandInvariantError"); + + const livenessSnapshotSequence = yield* engine.latestSequence; + for (const [taskType, expectedLiveness] of [ + ["subagent", "working"], + ["local_bash", "monitoring"], + ] as const) { + backgroundLiveness.recordTaskLiveness({ + threadId: liveThreadId, + taskId: `task-${expectedLiveness}`, + taskType, + status: undefined, + kind: "started", + }); + expect(backgroundLiveness.getThreadBackgroundLiveness(liveThreadId)).toBe( + expectedLiveness, + ); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + + const livenessError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }) + .pipe(Effect.flip); + expect(livenessError._tag).toBe("OrchestrationCommandInvariantError"); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + backgroundLiveness.clearThreadLiveness(liveThreadId); + } + + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }); + + const freshSnapshotSequence = yield* engine.latestSequence; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-unrelated-meta"), + threadId: unrelatedThreadId, + title: "Unrelated update", + }); + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"), + threadId: guardedThreadId, + snapshotSequence: freshSnapshotSequence, + }); + + const settled = yield* snapshots.getSnapshot(); + expect( + settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride, + ).toBe("settled"); + expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe( + "settled", + ); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + it("persists deterministic read models for repeated snapshot reads", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 423a44a6ff15..f6a928fdc704 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { + isOrchestrationCommandRejection, OrchestrationCommandIdConflictError, OrchestrationCommandInvariantError, OrchestrationCommandPreviouslyRejectedError, @@ -43,6 +44,7 @@ import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -51,7 +53,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is( OrchestrationCommandPreviouslyRejectedError, ); const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); -const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); interface CommandEnvelope { command: OrchestrationCommand; @@ -86,6 +87,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -169,13 +171,37 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + if ( + envelope.command.type === "thread.auto-settle" && + (yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: envelope.command.threadId, + sequenceExclusive: envelope.command.snapshotSequence, + })) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} changed before automatic settlement`, + }); + } + + if ( + envelope.command.type === "thread.auto-settle" && + threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} has live background work`, + }); + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((cause) => - isOrchestrationCommandInvariantError(cause) + isOrchestrationCommandRejection(cause) ? cause : new OrchestrationCommandInvariantError({ commandType: envelope.command.type, @@ -307,7 +333,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); - if (isOrchestrationCommandInvariantError(error)) { + if (isOrchestrationCommandRejection(error)) { yield* commandReceiptRepository .upsert({ commandId: envelope.command.commandId, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index b05ce3b1e235..1340480bce55 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { + it("starts every orchestration reactor", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -64,6 +65,15 @@ describe("OrchestrationReactor", () => { drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => { + started.push("thread-settlement-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "thread-settlement-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af0..649e803809db 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* threadSettlementReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4ad35e54cca8..ff246128c44c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -3229,4 +3229,49 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect("stops a ready provider session after automatic settlement", () => + Effect.gen(function* () { + const sessionStopped = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + stopSessionEffect: () => Deferred.succeed(sessionStopped, undefined).pipe(Effect.asVoid), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-auto-settle"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + const beforeSettlement = yield* Effect.promise(() => harness.readModel()); + + yield* harness.engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-with-session"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: beforeSettlement.snapshotSequence, + }); + + yield* Deferred.await(sessionStopped); + yield* Effect.promise(() => harness.drain()); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.settledOverride).toBe("settled"); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a37f0958dd55..84d472089d8d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -61,7 +61,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.settled"; } >; @@ -1490,6 +1491,24 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.settled": { + const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); + if ( + Option.isNone(thread) || + thread.value.session == null || + thread.value.session.status === "stopped" + ) { + return; + } + yield* orchestrationEngine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(`session-stop-for-settle:${event.commandId ?? event.eventId}`), + threadId: event.payload.threadId, + createdAt: event.occurredAt, + onlyIfSettled: true, + }); + return; + } } }); @@ -1528,7 +1547,8 @@ const make = Effect.gen(function* () { event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type === "thread.session-stop-requested" || + event.type === "thread.settled" ) { return yield* worker.enqueue(event); } diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts new file mode 100644 index 000000000000..08d2d2af24af --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProviderInstanceId, + ThreadId, + ProjectId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: "/repo", + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const decide = ( + thread: OrchestrationThreadShell, + pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + settings: { days?: number | null; merge?: boolean } = {}, +) => + shouldAutoSettleThread({ + thread, + pullRequest, + now: NOW, + autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, + autoSettleOnMerge: settings.merge ?? true, + }); + +describe("shouldAutoSettleThread", () => { + it("settles inactive threads and leaves never-used threads active", () => { + expect(decide(makeThread())).toBe(true); + expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); + expect(decide(makeThread(), null, { days: null })).toBe(false); + }); + + it("keeps a thread active at the exact inactivity boundary", () => { + expect(decide(makeThread({ latestUserMessageAt: "2026-08-25T12:00:00.000Z" }))).toBe(false); + }); + + it("keeps open pull requests active", () => { + expect(decide(makeThread(), { state: "open", updatedAt: NOW })).toBe(false); + }); + + it("settles closed requests and honors the merge setting", () => { + expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect( + decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + ).toBe(false); + }); + + it("does not settle again after user activity newer than the PR", () => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("does not inherit a terminal pull request older than the thread", () => { + expect( + decide( + makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), + { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("requires a comparable PR timestamp for immediate settlement", () => { + const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); + expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + }); + + it("uses user request time instead of completion time as the PR anchor", () => { + const thread = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-25T00:00:00.000Z", + startedAt: "2026-08-25T00:01:00.000Z", + completedAt: "2026-08-27T00:00:00.000Z", + assistantMessageId: null, + }, + }); + expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + }); + + it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { + expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); + expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); + expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "working" }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "monitoring" }))).toBe(false); + expect( + decide( + makeThread({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-1"), + lastError: null, + updatedAt: NOW, + }, + }), + ), + ).toBe(false); + expect( + decide(makeThread({ latestUserMessageAt: "2026-08-28T11:59:00.000Z", latestTurn: null })), + ).toBe(false); + }); + + it("allows a fresh completion to wake snooze before settlement", () => { + expect( + decide( + makeThread({ + snoozedAt: "2026-08-19T00:00:00.000Z", + snoozedUntil: "2026-08-29T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-woke"), + state: "completed", + requestedAt: "2026-08-18T00:00:00.000Z", + startedAt: "2026-08-18T00:01:00.000Z", + completedAt: "2026-08-20T00:00:00.000Z", + assistantMessageId: null, + }, + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts new file mode 100644 index 000000000000..5a10307956aa --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -0,0 +1,108 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export interface SettlementPullRequest { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +function latestTimestamp(values: ReadonlyArray): string | null { + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const value of values) { + if (value == null) continue; + const valueMs = Date.parse(value); + if (valueMs > latestMs) { + latest = value; + latestMs = valueMs; + } + } + return latest; +} + +/** A recent user message stays queued until a turn adopts its timestamp. + * Absolute age bounds client clock skew in both directions and stops stale + * pre-adoption data from blocking the thread forever. */ +export function threadHasQueuedTurnStart( + thread: Pick, + now: string, +): boolean { + if (thread.latestUserMessageAt === null || thread.session?.status === "error") return false; + const messageAt = Date.parse(thread.latestUserMessageAt); + const age = Date.parse(now) - messageAt; + if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false; + if (thread.latestTurn === null) return true; + return [ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].every((value) => value == null || Date.parse(value) < messageAt); +} + +function pullRequestSettles( + thread: Pick, + pullRequest: SettlementPullRequest, + autoSettleOnMerge: boolean, +): boolean { + if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { + return false; + } + if (pullRequest.updatedAt === null) return false; + const userAnchor = latestTimestamp([ + thread.createdAt, + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + ]); + if (userAnchor === null) return false; + const pullRequestAt = Date.parse(pullRequest.updatedAt); + const userAnchorAt = Date.parse(userAnchor); + if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; + return pullRequestAt >= userAnchorAt; +} + +export function shouldAutoSettleThread(input: { + readonly thread: OrchestrationThreadShell; + readonly pullRequest: SettlementPullRequest | null; + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge: boolean; +}): boolean { + const { thread, pullRequest } = input; + if (!isAutoSettlementCandidate(thread, input.now)) return false; + if (pullRequest !== null) { + if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true; + if (pullRequest.state === "open") return false; + } + if (input.autoSettleAfterDays === null) return false; + const activityAt = latestTimestamp([ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]); + if (activityAt === null) return false; + return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS; +} + +/** Cheap checks that run before any source control lookup. */ +export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { + if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (thread.session?.status === "starting" || thread.session?.status === "running") return false; + if (thread.backgroundLiveness != null) return false; + if (threadHasQueuedTurnStart(thread, now)) return false; + if (thread.snoozedUntil == null || Date.parse(thread.snoozedUntil) <= Date.parse(now)) + return true; + const wokeOnError = + thread.session?.status === "error" && + (thread.snoozedAt == null || + Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt)); + const wokeOnCompletion = + thread.snoozedAt != null && + thread.latestTurn?.state === "completed" && + thread.latestTurn.completedAt != null && + Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); + return wokeOnError || wokeOnCompletion; +} diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts new file mode 100644 index 000000000000..5d8d47109faa --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -0,0 +1,640 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestDetail, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { GitManager } from "../git/GitManager.ts"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("settlement-project"); +const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); + +type AutoSettleCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject( + id: ProjectId = PROJECT_ID, + workspaceRoot = "/workspace/project", +): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + projects: ReadonlyArray = [makeProject()], +): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects, + threads, + updatedAt: NOW, + }; +} + +function makePullRequestDetail(input: { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt?: string; +}): PullRequestDetail { + return { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + projectId: input.projectId, + projectTitle: "Linked project", + workspaceRoot: "/workspace/linked", + repository: input.repository, + number: input.number, + title: "Pull request", + body: "", + url: `https://example.test/${input.repository}/pull/${input.number}`, + author: null, + state: input.state, + isDraft: false, + mergeability: "mergeable", + additions: 0, + deletions: 0, + changedFiles: 0, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: input.updatedAt ?? NOW, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; +} + +interface HarnessOptions { + readonly snapshot: OrchestrationShellSnapshot; + readonly settings?: ServerSettings; + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; + readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; + readonly onDispatch?: ( + command: AutoSettleCommand, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReadCount = yield* Ref.make(0); + const snapshotReads = yield* Queue.unbounded(); + const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); + const settingsChanges = yield* PubSub.unbounded(); + const commands = yield* Ref.make>([]); + const branchCalls = yield* Ref.make< + ReadonlyArray<{ readonly cwd: string; readonly branch: string }> + >([]); + const detailCalls = yield* Ref.make< + ReadonlyArray<{ + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + }> + >([]); + + const updateSettings = (patch: ServerSettingsPatch) => + Effect.gen(function* () { + const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); + yield* Ref.set(settings, next); + yield* PubSub.publish(settingsChanges, next); + return next; + }); + + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => + Ref.update(branchCalls, (calls) => [...calls, input]).pipe( + Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), + ); + + const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => + Ref.update(detailCalls, (calls) => [...calls, input]).pipe( + Effect.andThen( + options.pullRequestDetail?.(input) ?? + Effect.succeed( + makePullRequestDetail({ + ...input, + state: "open", + }), + ), + ), + ); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type !== "thread.auto-settle") { + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + } + return Ref.update(commands, (recorded) => [...recorded, command]).pipe( + Effect.andThen(options.onDispatch?.(command) ?? Effect.void), + Effect.as({ sequence: 1 }), + ); + }; + + const serverSettings = ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settings), + updateSettings, + streamChanges: Stream.fromPubSub(settingsChanges), + subscribeChanges: PubSub.subscribe(settingsChanges).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }); + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( + Effect.tap((count) => Queue.offer(snapshotReads, count)), + Effect.andThen(Ref.get(snapshots)), + ), + }), + Layer.mock(GitManager)({ branchPullRequest }), + Layer.mock(PullRequestService)({ detail: pullRequestDetail }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerSettingsService, serverSettings), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReadCount, + snapshotReads, + commands, + branchCalls, + detailCalls, + updateSettings, + layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( + reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], + activation: Deferred.Deferred, + snapshotReads: Queue.Queue, +) { + yield* reactor.start(); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(snapshotReads); + yield* reactor.drain; +}); + +describe("ThreadSettlementReactor", () => { + it.effect("starts without clients and skips protected threads before pull request lookup", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const skipped = [ + makeThread("pending-approval", { + branch: "skip-approval", + hasPendingApprovals: true, + }), + makeThread("snoozed", { + branch: "skip-snoozed", + snoozedUntil: "2026-08-29T00:00:00.000Z", + }), + ]; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inactive", { branch: "inactive-feature" }), + makeThread("closed-pr", { linkedPullRequest }), + ...skipped, + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + branchPullRequest: () => Effect.succeed(null), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0); + + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + const commands = yield* Ref.get(fixture.commands); + assert.deepStrictEqual( + commands + .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("closed-pr"), + snapshotSequence: 1, + }, + { + threadId: ThreadId.make("inactive"), + snapshotSequence: 1, + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project", branch: "inactive-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reevaluates inactivity and pull request state once per minute", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const pullRequest = yield* Ref.make<"open" | "merged">("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("at-boundary", { + latestUserMessageAt: "2026-08-25T12:00:00.000Z", + }), + makeThread("open-pr", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + ]), + branchPullRequest: () => + Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + + yield* Ref.set(pullRequest, "merged"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map((command) => command.threadId) + .sort((left, right) => left.localeCompare(right)), + [ThreadId.make("at-boundary"), ThreadId.make("open-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"merged" | "closed">("merged"); + const firstLookupStarted = yield* Deferred.make(); + const releaseFirstLookup = yield* Deferred.make(); + const laterLookupStarted = yield* Deferred.make(); + const releaseLaterLookup = yield* Deferred.make(); + const lookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => + Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(firstLookupStarted, undefined) + : count === 3 + ? Deferred.succeed(laterLookupStarted, undefined) + : Effect.void, + ), + Effect.tap((count) => + count === 1 + ? Deferred.await(releaseFirstLookup) + : count === 3 + ? Deferred.await(releaseLaterLookup) + : Effect.void, + ), + Effect.andThen(Ref.get(state)), + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* Deferred.await(firstLookupStarted); + + yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false }); + yield* Deferred.succeed(releaseFirstLookup, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2); + + yield* Ref.set(state, "closed"); + yield* fixture.updateSettings({ enableAgentBrowserAccess: false }); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 }); + yield* Deferred.await(laterLookupStarted); + yield* Deferred.succeed(releaseLaterLookup, undefined); + yield* reactor.drain; + + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3); + assert.strictEqual(yield* Ref.get(lookupCount), 3); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("settings-thread")], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps an unknown pull request active and continues with other candidates", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("lookup-failed", { + linkedPullRequest: { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 9, + url: "https://example.test/owner/repository/pull/9", + }, + }), + makeThread("inactive-without-pr"), + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: () => + Effect.fail( + new PullRequestOperationError({ + operation: "detail", + detail: "host unavailable", + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("inactive-without-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps threads active when their pull request project is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 10, + url: "https://example.test/owner/repository/pull/10", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("missing-own-project", { linkedPullRequest }), + makeThread("missing-branch-project", { branch: "saved-feature" }), + ], + [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 77, + url: "https://example.test/owner/repository/pull/77", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("branch-one", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-one", + }), + makeThread("branch-two", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-two", + }), + makeThread("linked-one", { linkedPullRequest }), + makeThread("linked-two", { linkedPullRequest }), + ], + [ + makeProject(PROJECT_ID, "/workspace/project-root"), + makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), + ], + ), + branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project-root", branch: "saved-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, + ]); + assert.deepStrictEqual( + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), + new Set([ + ThreadId.make("branch-one"), + ThreadId.make("branch-two"), + ThreadId.make("linked-one"), + ThreadId.make("linked-two"), + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]), + onDispatch: (command) => + command.threadId === ThreadId.make("stale") + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "thread changed after settlement evaluation", + }), + ) + : Effect.void, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + const firstSweep = yield* Ref.get(fixture.commands); + assert.strictEqual( + firstSweep.find((command) => command.threadId === ThreadId.make("stale")) + ?.snapshotSequence, + 1, + ); + assert.strictEqual( + firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")), + true, + ); + + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts new file mode 100644 index 000000000000..fd4486a9c406 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -0,0 +1,185 @@ +import { CommandId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; +import { + isAutoSettlementCandidate, + shouldAutoSettleThread, + type SettlementPullRequest, +} from "./ThreadSettlementPolicy.ts"; + +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadSettlementReactor") {} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const settingsService = yield* ServerSettings.ServerSettingsService; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = DateTime.formatIso(yield* DateTime.now); + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const lookupKey = (thread: (typeof candidates)[number]) => { + if (thread.linkedPullRequest != null) { + return JSON.stringify([ + "linked", + thread.linkedPullRequest.projectId, + thread.linkedPullRequest.repository, + thread.linkedPullRequest.number, + ]); + } + if (thread.branch === null) return JSON.stringify(["none", thread.id]); + const project = projects.get(thread.projectId); + return JSON.stringify( + project === undefined + ? ["missing-project", thread.id] + : ["branch", project.workspaceRoot, thread.branch], + ); + }; + const groups = Map.groupBy(candidates, lookupKey); + + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( + thread: (typeof candidates)[number], + ) { + if (thread.linkedPullRequest != null) { + if (!projects.has(thread.linkedPullRequest.projectId)) { + return yield* Effect.die(new Error("linked pull request project not found")); + } + const detail = yield* pullRequests.detail({ + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }); + return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; + } + if (thread.branch === null) return null; + const project = projects.get(thread.projectId); + if (project === undefined) { + return yield* Effect.die(new Error("thread project not found")); + } + return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + }); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const pullRequest = yield* pullRequestFor(group[0]!); + yield* Effect.forEach( + group, + (thread) => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + if ( + !shouldAutoSettleThread({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) + ) { + return; + } + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + }); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( + "ThreadSettlementReactor.start", + )(function* () { + const settingsChanges = yield* settingsService.subscribeChanges; + const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); + let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; + let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + yield* forkParked( + Stream.runForEach(settingsChanges, (settings) => { + if ( + settings.sidebarAutoSettleAfterDays === lastAfterDays && + settings.sidebarAutoSettleOnMerge === lastOnMerge + ) { + return Effect.void; + } + lastAfterDays = settings.sidebarAutoSettleAfterDays; + lastOnMerge = settings.sidebarAutoSettleOnMerge; + return worker.enqueue(undefined); + }), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 26927d4499d6..e470ba33c790 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -19,6 +19,8 @@ import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; +const SETTLE_BLOCKED_MESSAGE = + "This thread still needs attention. Resolve or interrupt it first, then try again."; function makeReadModel( settledOverride: OrchestrationThread["settledOverride"], @@ -79,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi } it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("rejects an automatic settle when the thread is pinned active", () => + Effect.gen(function* () { + const command = { + type: "thread.auto-settle" as const, + commandId: CommandId.make("cmd-auto-settle"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + }; + const pinnedActive = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel("active"), + }).pipe(Effect.flip); + expect(pinnedActive._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + it.effect("settles awake threads without a redundant wake and re-emits idempotently", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ @@ -198,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, makeSession(status)), }).pipe(Effect.flip); - expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); } // Stopped/error sessions are settleable — only live work is protected. const settled = yield* decideOrchestrationCommand({ @@ -238,7 +260,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("approval.requested", "req-1", NOW), ]), }).pipe(Effect.flip); - expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + expect(openError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Same request later resolved: settleable again. const settled = yield* decideOrchestrationCommand({ @@ -266,7 +292,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("user-input.requested", "req-2", NOW), ]), }).pipe(Effect.flip); - expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + expect(inputError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -287,8 +317,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { createdAt: NOW, }) as OrchestrationThread["activities"][number]; - // Stale-failure detail clears the request — mirrors the projection's - // pending accounting, which is what the client's canSettle sees. + // Stale-failure details clear the request, matching the projection flags. const settled = yield* decideOrchestrationCommand({ command: { type: "thread.settle", @@ -324,7 +353,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ]), }).pipe(Effect.flip); - expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + expect(stillOpen).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -352,7 +385,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), }).pipe(Effect.flip); - expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + expect(queuedError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Message timestamp far in the FUTURE (client clock ahead of server): // a negative age must not read as queued forever — past the grace diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f3fdd462f437..892475f62447 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,13 +3,18 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; -import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, + type OrchestrationCommandRejection, +} from "./Errors.ts"; import { listThreadsByProjectId, requireActiveProjectWorkspaceRootAbsent, @@ -21,14 +26,10 @@ import { requireThreadNotArchived, } from "./commandInvariants.ts"; import { projectEvent } from "./projector.ts"; +import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -// Session adoption takes seconds; a user message still unadopted after this -// window is a failed/stale start, not pending work. Mirrors the client's -// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. -const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; - /** * Blocked-on-you work derived from the thread's retained activities: an * approval or user-input request with no later resolution for the same @@ -86,59 +87,28 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } -/** - * A queued turn start — a user message no turn has picked up yet — is work - * in flight even though session is still null (turn.start emits - * message-sent + turn-start-requested; the session arrives later). Detection - * mirrors the client's hasQueuedTurnStart: the newest user message is - * strictly newer than every latestTurn timestamp (adoption stamps the new - * turn's requestedAt with the message time, clearing this), and only within - * the adoption grace window — historical threads whose last user message - * postdates their turn timestamps (older-server data, mid-turn messages) - * must not be blocked forever. A failed session start (status "error") - * clears the block immediately. - * - * The age check is bounded on BOTH sides: message timestamps are - * client-supplied, so a client clock ahead of the server yields a negative - * age. Without the lower bound that negative age satisfies `<= grace` for - * as long as the skew lasts, extending the block far past the intended two - * minutes. - */ -function threadHasQueuedTurnStart( - thread: { - readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; - readonly latestTurn: { - readonly requestedAt: string; - readonly startedAt: string | null; - readonly completedAt: string | null; - } | null; - readonly session: { readonly status: string } | null; - }, - occurredAt: string, +/** Apply the shared shell-level rule to the detailed command read model. */ +function hasQueuedTurnStartForThread( + thread: Pick, + now: string, ): boolean { - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - return ( - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + let latestUserMessageAt: string | null = null; + let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; + for (const message of thread.messages) { + if (message.role !== "user") continue; + const messageAtMs = Date.parse(message.createdAt); + latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); + if (messageAtMs === latestUserMessageAtMs) { + latestUserMessageAt = message.createdAt; + } + } + return threadHasQueuedTurnStart( + { + latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null, + latestTurn: thread.latestTurn, + session: thread.session, + }, + now, ); } @@ -186,7 +156,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< ReadonlyArray, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { let nextReadModel = readModel; @@ -220,7 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { switch (command.type) { @@ -450,43 +420,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } - case "thread.settle": { + case "thread.settle": + case "thread.auto-settle": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); - // Server-side twin of the client's canSettle session check: a stale - // or raced client must not settle a thread whose session is coming - // alive or working. - if (thread.session?.status === "starting" || thread.session?.status === "running") { + if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `thread ${command.threadId} has an active session and cannot be settled`, + detail: `thread ${command.threadId} changed before automatic settlement`, }), ); } + // The server owns settle eligibility. A stale command must not settle + // a thread whose session is coming alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); + } // Pending approval / user-input requests are blocked-on-you work: a // raced or stale client must not park them behind a settled override // that would surface only after the request resolves. if (hasOpenBlockingRequest(thread)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, - }), - ); + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const occurredAt = yield* nowIso; // Settling inside the adoption window would hide just-requested work. - if (threadHasQueuedTurnStart(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, - }), - ); + if (hasQueuedTurnStartForThread(thread, occurredAt)) { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / @@ -610,7 +573,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. - if (threadHasQueuedTurnStart(thread, occurredAt)) { + if (hasQueuedTurnStartForThread(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -1152,7 +1115,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if ( thread.settledOverride !== "settled" || sessionComingAlive || - threadHasQueuedTurnStart(thread, command.createdAt) + hasQueuedTurnStartForThread(thread, command.createdAt) ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index edd8620c3b3f..e801c34af582 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -64,7 +64,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ const HasEventAfterRequestSchema = Schema.Struct({ aggregateKind: Schema.String, aggregateId: Schema.String, - type: Schema.String, + type: Schema.optional(Schema.String), sequenceExclusive: NonNegativeInt, }); @@ -271,16 +271,17 @@ const makeEventStore = Effect.gen(function* () { const findEventAfter = SqlSchema.findOneOption({ Request: HasEventAfterRequestSchema, Result: Schema.Struct({ sequence: Schema.Number }), - execute: (request) => - sql` - SELECT sequence - FROM orchestration_events - WHERE aggregate_kind = ${request.aggregateKind} - AND stream_id = ${request.aggregateId} - AND event_type = ${request.type} - AND sequence > ${request.sequenceExclusive} - LIMIT 1 - `, + execute: (request) => sql` + SELECT sequence + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.aggregateId} + AND ${sql.and([ + sql`sequence > ${request.sequenceExclusive}`, + ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]), + ])} + LIMIT 1 + `, }); const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 488210ab74a5..b865957c06b3 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -54,7 +54,8 @@ export interface OrchestrationEventStoreShape { readonly readAll: () => Stream.Stream; /** - * Check whether an aggregate has an event of the given type after a sequence. + * Check whether an aggregate has an event after a sequence, optionally + * restricted to one event type. * * Used during replay to tell whether a later event supersedes the one being * applied, without streaming the rest of the log. @@ -62,7 +63,7 @@ export interface OrchestrationEventStoreShape { readonly hasEventAfter: (input: { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; readonly aggregateId: string; - readonly type: OrchestrationEvent["type"]; + readonly type?: OrchestrationEvent["type"]; readonly sequenceExclusive: number; }) => Effect.Effect; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 93a3977ddd0f..8170901a21ff 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -118,7 +118,10 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; -import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; +import { + OrchestrationListenerCallbackError, + OrchestrationThreadSettleBlockedError, +} from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; @@ -8036,7 +8039,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session after settle without closing terminals", () => + it.effect("leaves settle cleanup to the event reactor", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-settle"); const effects: string[] = []; @@ -8094,64 +8097,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); - const sessionStopCommand = dispatchedCommands[1]; - assert.equal(sessionStopCommand?.type, "thread.session.stop"); - if (sessionStopCommand?.type === "thread.session.stop") { - assert.equal(sessionStopCommand.threadId, threadId); - assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); - assert.equal(sessionStopCommand.onlyIfSettled, true); - } + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("settles without dispatching session stop when the thread has no session", () => + it.effect("forwards the friendly blocked-settlement message over websocket rpc", () => Effect.gen(function* () { - const threadId = ThreadId.make("thread-settle-no-session"); - const effects: string[] = []; - const dispatchedCommands: Array = []; - + const threadId = ThreadId.make("thread-settle-blocked"); yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - effects.push(`dispatch:${command.type}`); - return { sequence: dispatchedCommands.length }; - }), - }, - projectionSnapshotQuery: { - getThreadShellById: () => - Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), - ), + dispatch: () => Effect.fail(new OrchestrationThreadSettleBlockedError({ threadId })), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const dispatchResult = yield* Effect.scoped( + const error = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.settle", - commandId: CommandId.make("cmd-thread-settle-no-session"), + commandId: CommandId.make("cmd-thread-settle-blocked"), threadId, }), - ), + ).pipe(Effect.flip), ); - assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle"]); - assert.deepEqual( - dispatchedCommands.map((command) => command.type), - ["thread.settle"], + assert.equal(error._tag, "OrchestrationDispatchCommandError"); + assert.equal( + error.message, + "This thread still needs attention. Resolve or interrupt it first, then try again.", ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9a05fd18517e..631902ac087d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -62,6 +62,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -257,6 +258,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -290,6 +292,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +const PullRequestServiceLive = PullRequestService.layer.pipe( + Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(VcsProcess.layer), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer), Layer.provideMerge(GitVcsDriver.layer), @@ -389,7 +398,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), - Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge( + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), + ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), @@ -458,14 +469,6 @@ const commandReadinessLayer = HttpRouter.middleware( { global: true }, ); -const PullRequestServiceLive = PullRequestService.layer.pipe( - // One registry entry per supported host; the service only knows the registry. - Layer.provide(PullRequestProviderRegistry.layer), - Layer.provide(SourceControlProviderRegistryLayerLive), - Layer.provide(SourceControlRateLimit.layer), - Layer.provide(VcsProcess.layer), -); - export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 521865839bde..82ed8525b9b5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -272,6 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists and broadcasts thread settlement settings", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + + const next = yield* serverSettings.updateSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // Inspect raw persisted JSON before schema decoding can apply defaults. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw) as Record; + + assert.strictEqual(next.sidebarAutoSettleAfterDays, null); + assert.isFalse(next.sidebarAutoSettleOnMerge); + assert.strictEqual(change?.sidebarAutoSettleAfterDays, null); + assert.isFalse(change?.sidebarAutoSettleOnMerge); + assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null); + assert.isFalse(persisted.sidebarAutoSettleOnMerge); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4da8420799e9..db3b74b7e4b6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1223,23 +1223,17 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - // Archive and settle both mean "done with this thread", so a - // live provider session must not keep running background work - // (PR monitors, dev servers, subagent fleets) after either - // lands. The decider rejects settling a starting/running - // session, so for settle this only ever stops an idle one; a - // stopped session-set does not count as activity, so the stop - // cannot un-settle the thread it follows. - const parkingCommand = - normalizedCommand.type === "thread.archive" || - normalizedCommand.type === "thread.settle" - ? normalizedCommand - : undefined; - // Best-effort on purpose: the user's archive/settle must not + // Archive removes the thread from the client, so this transport + // closes its session and terminals after the command lands. + // Settlement cleanup is driven by thread.settled events in the + // provider reactor, including settlements that have no client. + const archiveCommand = + normalizedCommand.type === "thread.archive" ? normalizedCommand : undefined; + // Best-effort on purpose: the user's archive must not // fail because this cleanup read blipped, so a failed read // logs and skips the stop instead of propagating. - const shouldStopSessionAfterCommand = parkingCommand - ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + const shouldStopSessionAfterCommand = archiveCommand + ? yield* projectionSnapshotQuery.getThreadShellById(archiveCommand.threadId).pipe( Effect.map( Option.match({ onNone: () => false, @@ -1250,7 +1244,7 @@ const makeWsRpcLayer = ( Effect.catchCause((cause) => Effect.logWarning( "failed to read thread session state before session-stop check", - { threadId: parkingCommand.threadId, cause }, + { threadId: archiveCommand.threadId, cause }, ).pipe(Effect.as(false)), ), ) @@ -1259,50 +1253,39 @@ const makeWsRpcLayer = ( Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), ); yield* recordClientCommandAnalytics(normalizedCommand); - if (parkingCommand) { - const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (archiveCommand) { if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, + `session-stop-for-archive:${archiveCommand.commandId}`, ), - threadId: parkingCommand.threadId, + threadId: archiveCommand.threadId, createdAt: yield* nowIso, - // A settled thread can be re-engaged before this stop is - // decided; the decider then drops the stop instead of - // killing the new session. Archive stops stay - // unconditional: turn starts on archived threads are - // rejected, so there is no new session to protect. - ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { - threadId: parkingCommand.threadId, + Effect.logWarning("failed to stop provider session during archive", { + threadId: archiveCommand.threadId, cause, }), ), ); } - // Terminals are user-opened panes, not thread background - // work: archive removes the thread from view so they close - // with it, but a settled thread stays reachable and may be - // un-settled, so its terminals stay up. - if (parkingCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: parkingCommand.threadId, - error: error.message, - }), - ), - ); - } + // Archive removes the thread from view, so its user-opened + // terminal panes close with it. + yield* terminalManager.close({ threadId: archiveCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: archiveCommand.threadId, + error: error.message, + }), + ), + ); } return result; }).pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index be978a8cc0c7..c222bb3bf0a2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -30,12 +30,7 @@ import { } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; -import { - changeRequestAutoSettles, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -4539,9 +4534,7 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - // Settled state of the open thread, resolved exactly like the sidebar - // partition (same shell, same capability gate, same PR auto-settle input) - // so the banner and the sidebar row never disagree. + // The server-projected settled state keeps the banner and sidebar in sync. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const activeComposerTasksProgress = useMemo(() => { if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { @@ -4561,7 +4554,6 @@ function ChatViewContent(props: ChatViewProps) { activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId ? activePlan.steps : null; - useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -4585,9 +4577,6 @@ function ChatViewContent(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement]); - - const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const linkedPullRequestStatus = useLinkedThreadPullRequest( activeThreadRef?.environmentId ?? null, linkedThreadPullRequest, @@ -4608,18 +4597,6 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadPr, openThreadPullRequest]); const pullRequestSurfaceAvailable = supportsPullRequests && activeThreadPr !== null && threadRepository !== null; - // Primitive slice of the displayed PR for the settle-rule memos below: - // resolveDisplayedThreadPr returns a fresh object every render, so memoize - // on the fields the rules read instead of the object identity. - const activeThreadPrState = activeThreadPr?.state ?? null; - const activeThreadPrUpdatedAt = activeThreadPr?.updatedAt ?? null; - const activeThreadChangeRequest = useMemo( - () => - activeThreadPrState === null - ? null - : { state: activeThreadPrState, updatedAt: activeThreadPrUpdatedAt }, - [activeThreadPrState, activeThreadPrUpdatedAt], - ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -4650,21 +4627,13 @@ function ChatViewContent(props: ChatViewProps) { if (activeThreadRef === null || activeThreadWokeAt === null) return; markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread. It uses the same - // visit comparison and change request settle rule. + // Mirror of the sidebar's Woke pill for the open thread. const activeThreadLastVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; - if ( - changeRequestAutoSettles(activeThreadChangeRequest, { - autoSettleOnMerge, - thread: activeThreadShell, - }) - ) { - return false; - } + if (activeThreadShell?.settledOverride === "settled") return false; const wokeAtMs = Date.parse(activeThreadWokeAt); if (Number.isNaN(wokeAtMs)) return false; // Having the thread open counts as a visit at completedAt (the effect @@ -4684,28 +4653,11 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, - activeThreadChangeRequest, activeThreadShell, activeThreadWokeAt, - autoSettleOnMerge, - ]); - const activeThreadSettled = useMemo(() => { - if (activeThreadShell === null || !supportsSettlement) return false; - return effectiveSettled(activeThreadShell, { - now: `${nowMinute}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest: activeThreadChangeRequest, - }); - }, [ - activeThreadChangeRequest, - activeThreadShell, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - supportsSettlement, ]); + const activeThreadSettled = + supportsSettlement && activeThreadShell?.settledOverride === "settled"; const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); @@ -7158,7 +7110,6 @@ function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - changeRequest={activeThreadChangeRequest} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} activeProjectFaviconPath={activeProject?.faviconPath ?? null} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6e359f85c588..2bcdaf761628 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -618,11 +618,8 @@ type SettledTimestampInput = Pick< "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" >; -/** The timestamp a settled row sorts and labels by: settledAt when stamped - (explicit settles), otherwise last activity — the same candidates - threadLastActivityAt feeds the auto-settle window (user message plus all - latestTurn stamps), so a thread whose last activity was a turn completion - doesn't sort by an older message time. updatedAt is the final net. */ +/** The timestamp a settled row sorts and labels by: settledAt when stamped, + otherwise the latest message or turn stamp. updatedAt is the final net. */ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { const settledAt = firstValidTimestamp(thread.settledAt); if (settledAt !== null) return settledAt; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a5df7bc53538..0b85065e134c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,8 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { canSnooze, - changeRequestAutoSettles, - effectiveSettled, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; @@ -715,7 +713,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Pinned threads show the same pin marker in active, settled, and snoozed @@ -840,10 +837,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - !changeRequestAutoSettles(pr, { - autoSettleOnMerge: props.autoSettleOnMerge, - thread, - }); + thread.settledOverride !== "settled"; // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -1740,8 +1734,6 @@ export default function Sidebar() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -1938,8 +1930,6 @@ export default function Sidebar() { [projectGroups], ); - // now is quantized to the minute so effectiveSettled memoization doesn't - // churn on every render; auto-settle thresholds are day-granular anyway. const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized // minute would hold a woken thread on the shelf for up to a minute. The @@ -2081,7 +2071,6 @@ export default function Sidebar() { settledThreads, snoozeNow, } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: // wake times are second-precise and a woken thread must not linger on // the shelf for the rest of the minute. snoozeWakeTick re-runs this @@ -2107,29 +2096,10 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshot = changeRequestSnapshotByKey.get(threadKey); - const changeRequest = - snapshot != null && - (thread.linkedPullRequest == null - ? thread.worktreePath === null || snapshot.branch === thread.branch - : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId && - snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository && - snapshot.linkedPullRequest.number === thread.linkedPullRequest.number) - ? snapshot.pr - : null; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); @@ -2163,16 +2133,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [ - autoSettleAfterDays, - autoSettleOnMerge, - changeRequestSnapshotByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3111,9 +3072,8 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without + // Un-settle pins the thread active until real activity clears the pin. + // Environments without // the settlement capability get no lifecycle items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === @@ -3777,9 +3737,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} - // Snoozed rows wake; settled rows un-settle (explicit - // settles clear the override, auto-settled rows get - // pinned active); cards settle. + // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" ? "unsnooze" @@ -3791,7 +3749,6 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } - autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3710bcea8b8e..2663af161b55 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,6 +1,4 @@ -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; -import type { OrchestrationThreadShell } from "@t3tools/contracts"; -import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -495,7 +493,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toEqual(mergedPr); }); - it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + it("retains a merged PR after a main checkout", () => { const matchingStatus = status({ refName: featureBranch, pr: mergedPr, @@ -518,36 +516,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { retainTerminalOnBranchMismatch: true, }); expect(displayed?.state).toBe("merged"); - - const shell = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Feature thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - latestTurn: null, - session: null, - createdAt: "2026-04-09T00:00:00.000Z", - updatedAt: "2026-04-09T00:00:00.000Z", - archivedAt: null, - settledAt: null, - settledOverride: null, - latestUserMessageAt: "2026-04-09T00:00:00.000Z", - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - } as OrchestrationThreadShell; - - expect( - effectiveSettled(shell, { - now: "2026-04-10T00:00:00.000Z", - autoSettleAfterDays: null, - changeRequest: displayed, - }), - ).toBe(true); }); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c9c733e10f19..6964702726ef 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -10,7 +10,6 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; import { ChevronDownIcon } from "lucide-react"; import { memo, @@ -53,8 +52,6 @@ interface ChatHeaderProps { activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; - /** PR feeding the settled classification, resolved by ChatView. */ - changeRequest: ChangeRequestSettleSource | null; activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; @@ -123,7 +120,6 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, isServerThread, - changeRequest, activeProjectName, activeProjectCwd, activeProjectFaviconPath, @@ -201,7 +197,6 @@ export const ChatHeader = memo(function ChatHeader({ const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, - changeRequest, onStartRename: startRename, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b150533b0044..851605816975 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -79,7 +79,11 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; +import { + primaryServerConfigAtom, + primaryServerObservabilityAtom, + primaryServerProvidersAtom, +} from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -1871,6 +1875,8 @@ export function GeneralSettingsPanel() { ); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); + const supportsAutoSettlement = + useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -1954,72 +1960,77 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - }) - } - /> - ) : null - } - control={ - - updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + {supportsAutoSettlement ? ( + <> + + updateSettings({ + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + } + aria-label="Auto-settle merged threads" + /> } - aria-label="Auto-settle merged threads" /> - } - /> - - updateSettings({ - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) + + updateSettings({ + sidebarAutoSettleAfterDays: + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> } - aria-label="Auto-settle inactive threads" /> - } - /> - {settings.sidebarAutoSettleAfterDays !== null ? ( - updateSettings({ sidebarAutoSettleAfterDays: days })} + {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } /> - } - /> + ) : null} + ) : null} { it("hides desktop-only settings from browser search", () => { expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); - expect(searchSettings("quit confirmation")).toEqual([]); + expect(searchSettings("hold to quit")).toEqual([]); expect(searchSettings("wsl")).toEqual([]); }); @@ -134,6 +134,7 @@ describe("searchSettings", () => { hasProviderSettingsEnvironment: false, canManageLocalBackend: false, isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, }); const gatedIds = new Set([ @@ -147,10 +148,30 @@ describe("searchSettings", () => { "t3-connect", "tailscale-https", "wsl-backend", + "auto-settle-inactive-threads", + "auto-settle-merged-threads", + "days-before-auto-settle", ]); expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("shows automatic settlement settings when the server supports them", () => { + const available = filterAvailableSettingsSearchItems({ + hasCloudPublicConfig: false, + hasPrimaryEnvironment: false, + hasProviderSettingsEnvironment: false, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: true, + }); + + expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([ + "auto-settle-inactive-threads", + "auto-settle-merged-threads", + "days-before-auto-settle", + ]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 1a061a4632a6..0432994962a6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -30,6 +30,7 @@ export interface SettingsSearchItem { readonly providerSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; readonly wslAvailableOnly?: boolean; + readonly requiresThreadAutoSettlement?: boolean; } export interface SettingsSearchAvailability { @@ -38,6 +39,7 @@ export interface SettingsSearchAvailability { readonly hasProviderSettingsEnvironment: boolean; readonly canManageLocalBackend: boolean; readonly isWslSettingsRowVisible: boolean; + readonly hasThreadAutoSettlement: boolean; } /** @@ -148,12 +150,14 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Auto-settle inactive threads", to: "/settings/general", searchTerms: ["sidebar inactivity days no activity automatically"], + requiresThreadAutoSettlement: true, }, { id: "auto-settle-merged-threads", title: "Auto-settle merged threads", to: "/settings/general", searchTerms: ["pull request merge closed automatically sidebar"], + requiresThreadAutoSettlement: true, }, { id: "days-before-auto-settle", @@ -161,6 +165,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", targetId: "auto-settle-inactive-threads", searchTerms: ["thread timeout activity sidebar"], + requiresThreadAutoSettlement: true, }, { id: "time-format", @@ -461,7 +466,8 @@ export function filterAvailableSettingsSearchItems( (!item.primaryOnly || availability.hasPrimaryEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && - (!item.wslAvailableOnly || availability.isWslSettingsRowVisible), + (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && + (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), ); } diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 2d426d763ed2..a2f5ca62766f 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useAtomValue } from "@effect/atom-react"; import { AuthAccessWriteScope } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { usePrimarySessionState } from "~/environments/primary"; +import { primaryServerConfigAtom } from "~/state/server"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; import { filterAvailableSettingsSearchItems } from "./settingsSearch"; @@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); + const primaryServerConfig = useAtomValue(primaryServerConfigAtom); const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); const canManageLocalBackend = isElectron || @@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { state: desktopWsl.data, error: desktopWsl.error, }), + hasThreadAutoSettlement: + primaryServerConfig?.environment.capabilities.threadAutoSettlement === true, }), - [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId], + [ + canManageLocalBackend, + desktopWsl.data, + desktopWsl.error, + environments, + primaryEnvironmentId, + primaryServerConfig, + ], ); } diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 1b9f77b2189b..81168e5459cc 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,10 +1,7 @@ import { useSyncExternalStore } from "react"; -/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. - One module-level timer feeds every consumer through useSyncExternalStore, - so all surfaces resolving effectiveSettled against it (sidebar partition, - composer banner) share a single value by construction and tick on UTC - minute boundaries together. */ +/** Minute-quantized UI clock ("YYYY-MM-DDTHH:MM"). One module-level timer + feeds every consumer through useSyncExternalStore. */ function currentMinute(): string { return new Date().toISOString().slice(0, 16); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index b332fe13c2f1..b3009b260520 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -76,4 +76,22 @@ describe("mergeEnvironmentSettings", () => { expect(settings.providerInstances).toBe(serverSettings.providerInstances); expect(settings.favorites).toBe(clientSettings.favorites); }); + + it("keeps server settlement settings when legacy client data contains retired keys", () => { + const serverSettings = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 14, + sidebarAutoSettleOnMerge: false, + }; + const legacyClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + sidebarAutoSettleAfterDays: 1, + sidebarAutoSettleOnMerge: true, + }; + + const settings = mergeEnvironmentSettings(serverSettings, legacyClientSettings); + + expect(settings.sidebarAutoSettleAfterDays).toBe(14); + expect(settings.sidebarAutoSettleOnMerge).toBe(false); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 5e633a5ded59..39ec7a70ce42 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -216,7 +216,9 @@ export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, ): UnifiedSettings { - return { ...serverSettings, ...clientSettings }; + // Decode drops retired client keys, but older untyped persistence adapters + // can still return them. Server-owned values must always win. + return { ...clientSettings, ...serverSettings }; } function useMergedSettings( diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 1602e5cf816d..a073d847051b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -5,12 +5,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { - canSnooze, - effectiveSettled, - effectiveSnoozed, - type ChangeRequestSettleSource, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useCallback } from "react"; @@ -60,11 +55,9 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; /** Fallback for "Copy path" when the thread has no worktree. */ readonly projectCwd: string | null; - /** PR feeding auto-settle classification, as resolved by the caller. */ - readonly changeRequest: ChangeRequestSettleSource | null; readonly onStartRename: () => void; }) { - const { threadRef, projectCwd, changeRequest, onStartRename } = input; + const { threadRef, projectCwd, onStartRename } = input; const { settleThread, unsettleThread, @@ -80,8 +73,6 @@ export function useThreadActionMenu(input: { }); const handleNewThread = useNewThreadHandler(); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -127,17 +118,7 @@ export function useThreadActionMenu(input: { const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, - isSettled: - supports.settlement && - effectiveSettled(thread, { - // Minute-quantized like useNowMinute, so this classification - // can never disagree with the sidebar partition or ChatView's - // parked-thread banner within the same minute. - now: `${now.toISOString().slice(0, 16)}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }), + isSettled: supports.settlement && thread.settledOverride === "settled", isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, @@ -311,9 +292,6 @@ export function useThreadActionMenu(input: { }, [ archiveThread, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, confirmThreadArchive, confirmThreadDelete, confirmAndUnpinThread, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 527081dcf7ac..64915228c779 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -5,7 +5,7 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -64,18 +64,6 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass()( - "ThreadSettleBlockedError", - { - environmentId: EnvironmentId, - threadId: ThreadId, - }, -) { - override get message(): string { - return "This thread still needs attention. Resolve or interrupt it first, then try again."; - } -} - export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass()( "ThreadSnoozeUnsupportedError", { @@ -506,19 +494,6 @@ export function useThreadActions() { ); } const resolved = resolveThreadTarget(target); - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { - return AsyncResult.failure( - Cause.fail( - new ThreadSettleBlockedError({ - environmentId: resolved.threadRef.environmentId, - threadId: resolved.threadRef.threadId, - }), - ), - ); - } const wokeAt = resolved ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) : null; diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 6c5a61cd7d01..3761cbaf87b3 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -88,17 +88,28 @@ A turn is complete when its session leaves `running` status, projected by `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later does not define turn end. +Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. +[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and +once per minute, including when no client is connected. It dispatches the guarded internal +`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic +settlement excludes live background work and requires a comparable PR timestamp for immediate PR +settlement. The command also rejects any later event for its thread after the reactor's snapshot. +Clients render the persisted settlement state and do not derive settlement from PR or inactivity +state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider +session. + ## Drainable workers Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]: [`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands, -[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, and -[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints. +[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, +[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints, and +[`ThreadSettlementReactor`][settlement] evaluates server-owned automatic settlement rules. `DrainableWorker` pairs a transactional queue with a transactional count of outstanding items. `enqueue` atomically offers and increments; processing always decrements. `drain` retries until the count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. -Each of the three services exposes `drain` for exactly this. +Each of these four services exposes `drain` for exactly this. Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not @@ -150,5 +161,6 @@ already dispatch. [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts +[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts [receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts [drivers]: ../../apps/server/src/provider/builtInDrivers.ts diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f0fbfdccbd6c..8240723999de 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -10,6 +10,17 @@ confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p Pinned threads still move to **Settled** when they become inactive. They also move when their pull request merges if **Auto-settle merged threads** is enabled. +Each environment owns its automatic settlement settings. The server checks them even when no web, +desktop, or mobile client is connected. By default, it settles threads after three days without +activity and when their pull request merges. An eligible idle thread also settles when its pull +request closes. An open pull request blocks inactivity settlement. Active work, pending input, and +live background work keep the thread active. T3 Code settles from a closed or merged pull request +only when its timestamp is not older than the user's latest activity. If that timestamp is not +available, the inactivity rule still applies. A manual un-settle also keeps the thread active. +Change these rules in **Settings > General** for the environment. A settings change affects future +settlement and does not reopen a settled thread. Settings saved by older clients on one device no +longer control this behavior. + When you un-settle a thread, it returns to the top of the active list so you can find it right away. Its timestamps do not change. Other threads keep their positions. diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts deleted file mode 100644 index 06a8bb32c793..000000000000 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThreadShell, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - canSettle, - changeRequestAutoSettles, - effectiveSettled, - hasQueuedTurnStart, - threadLastActivityAt, - type ChangeRequestStateLike, -} from "./threadSettled.ts"; - -const NOW = "2026-04-10T00:00:00.000Z"; -const FRESH = "2026-04-09T00:00:00.000Z"; -const STALE = "2026-04-06T23:59:59.999Z"; - -describe("changeRequestAutoSettles", () => { - it.each([ - ["open", true, false], - ["merged", true, true], - ["merged", false, false], - ["closed", false, true], - [null, false, false], - ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { - expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe( - expected, - ); - }); - - const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z"; - const idleThread = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: null, - latestTurn: null, - }; - - it("ignores a terminal change request last touched before the thread existed", () => { - for (const state of ["merged", "closed"] as const) { - expect( - changeRequestAutoSettles( - { state, updatedAt: "2026-03-31T23:59:59.999Z" }, - { thread: idleThread }, - ), - ).toBe(false); - } - }); - - it("settles on a terminal change request touched at or after the thread's latest event", () => { - for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) { - expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe( - true, - ); - } - }); - - it("never re-settles a thread revived after the merge", () => { - // Settling on a merge happens once: a user message newer than the PR's - // last activity means the conversation outlived the PR. - const revived = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-05T00:00:00.000Z", - latestTurn: null, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(false); - // A merge landing after the revival still settles. - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(true); - }); - - it("still settles when the merge lands during an in-flight turn", () => { - // Anchor is user-initiated activity only: the agent finishing a turn - // after the merge must not block the settle the merge earned. - const midTurnMerge = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-02T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-mid"), - state: "completed" as const, - requestedAt: "2026-04-02T00:00:00.000Z", - startedAt: "2026-04-02T00:00:05.000Z", - completedAt: "2026-04-02T00:20:00.000Z", - assistantMessageId: null, - }, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" }, - { thread: midTurnMerge }, - ), - ).toBe(true); - }); - - it("falls back to settling when either timestamp is missing or malformed", () => { - expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }), - ).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}), - ).toBe(true); - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "not-a-date" }, - { thread: idleThread }, - ), - ).toBe(true); - }); -}); - -function makeShell(input: { - readonly settledOverride?: "settled" | "active" | null; - readonly activityAt: string | null; - readonly sessionStatus?: "starting" | "running"; - readonly pending?: "approval" | "user-input"; -}): OrchestrationThreadShell { - const threadId = ThreadId.make("thread-1"); - return { - id: threadId, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: - input.activityAt === null - ? null - : { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: input.activityAt, - startedAt: null, - completedAt: null, - assistantMessageId: null, - }, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: NOW, - archivedAt: null, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledOverride === "settled" ? NOW : null, - session: - input.sessionStatus === undefined - ? null - : { - threadId, - status: input.sessionStatus, - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, - }, - latestUserMessageAt: null, - hasPendingApprovals: input.pending === "approval", - hasPendingUserInput: input.pending === "user-input", - hasActionableProposedPlan: false, - }; -} - -describe("threadLastActivityAt", () => { - it("returns the latest real user or turn activity and ignores thread/session updates", () => { - const shell = makeShell({ activityAt: null, sessionStatus: "running" }); - const withActivity: OrchestrationThreadShell = { - ...shell, - latestUserMessageAt: "2026-04-04T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-03T00:00:00.000Z", - startedAt: "2026-04-05T00:00:00.000Z", - completedAt: "2026-04-06T00:00:00.000Z", - assistantMessageId: null, - }, - }; - - expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); - expect(threadLastActivityAt(shell)).toBeNull(); - }); -}); - -describe("effectiveSettled", () => { - const overrideCases = [null, "settled", "active"] as const; - const changeRequestStates = [undefined, "open", "merged"] as const; - const inactivityCases = [ - ["fresh", FRESH], - ["stale", STALE], - ["no-activity", null], - ] as const; - const runningCases = [false, true] as const; - const pendingCases = [undefined, "approval", "user-input"] as const; - const truthTable = overrideCases.flatMap((settledOverride) => - changeRequestStates.flatMap((changeRequestState) => - inactivityCases.flatMap(([inactivity, activityAt]) => - runningCases.flatMap((running) => - pendingCases.map((pending) => ({ - settledOverride, - changeRequestState, - inactivity, - activityAt, - running, - pending, - // Settled iff nothing blocks (pending work / live session) AND - // the override says settled, or (with no override) a merged PR - // or staleness auto-settles. The "active" pin suppresses both - // auto signals, and an open PR suppresses the inactivity path: - // a thread with a PR out for review is never done, however quiet. - expected: - pending === undefined && - !running && - (settledOverride === "settled" || - (settledOverride === null && - (changeRequestState === "merged" || - (changeRequestState !== "open" && inactivity === "stale")))), - })), - ), - ), - ), - ); - - it.each(truthTable)( - "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", - ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { - const shell = makeShell({ - settledOverride, - activityAt, - ...(running ? { sessionStatus: "running" as const } : {}), - ...(pending === undefined ? {} : { pending }), - }); - const changeRequestOptions = - changeRequestState === undefined - ? {} - : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } }; - - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - ...changeRequestOptions, - }), - ).toBe(expected); - }, - ); - - it("treats closed change requests like merged ones", () => { - const shell = makeShell({ activityAt: null }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("settles immediately when a change request merges or closes", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - for (const changeRequestState of ["merged", "closed"] as const) { - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: changeRequestState }, - }), - ).toBe(true); - } - }); - - it("ignores a change request that merged before the thread's latest event", () => { - // A new thread started at a worktree root inherits the branch's old - // merged PR, and a revived thread outlives its merge; neither settles - // the live conversation. - const fresh = makeShell({ activityAt: FRESH }); - for (const state of ["merged", "closed"] as const) { - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" }, - }), - ).toBe(false); - } - // A merge during the thread's life still settles it. - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" }, - }), - ).toBe(true); - }); - - it("can keep a merged change request active", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("never auto-settles a stale thread with an open change request", () => { - const stale = makeShell({ activityAt: STALE }); - expect( - effectiveSettled(stale, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), - ).toBe(false); - // An explicit user settle still wins: open PR only blocks the auto path. - const settled = makeShell({ settledOverride: "settled", activityAt: STALE }); - expect( - effectiveSettled(settled, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), - ).toBe(true); - }); - - it("keeps an explicitly un-settled merged-PR thread active", () => { - const shell = makeShell({ - settledOverride: "active", - activityAt: "2026-04-09T23:59:59.999Z", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - }); - - it("never settles a starting session, even with a settled override", () => { - const shell = makeShell({ - settledOverride: "settled", - activityAt: STALE, - sessionStatus: "starting", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - }); - - it("keeps a new turn active from queued through starting and running", () => { - const requestedAt = "2026-04-09T12:00:00.000Z"; - const transitionNow = "2026-04-09T12:00:30.000Z"; - const base = makeShell({ - settledOverride: null, - activityAt: STALE, - }); - const queued: OrchestrationThreadShell = { - ...base, - latestUserMessageAt: requestedAt, - latestTurn: null, - session: null, - }; - const starting: OrchestrationThreadShell = { - ...queued, - session: { - threadId: queued.id, - status: "starting", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: requestedAt, - }, - }; - const running: OrchestrationThreadShell = { - ...starting, - session: { - ...starting.session!, - status: "running", - activeTurnId: TurnId.make("turn-new"), - }, - }; - - for (const shell of [queued, starting, running]) { - expect( - effectiveSettled(shell, { - now: transitionNow, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - } - }); - - it("uses a strict inactivity boundary and honors a null threshold", () => { - const boundary = makeShell({ - activityAt: "2026-04-07T00:00:00.000Z", - }); - const stale = makeShell({ activityAt: STALE }); - - expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); - }); -}); - -describe("hasQueuedTurnStart", () => { - const QUEUED_AT = "2026-04-09T12:00:00.000Z"; - // Within the adoption grace window of the queued message. - const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; - - it("flags a user message no turn has picked up, within the grace window", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); - - const staleTurn = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); - }); - - it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); - // Historical shells (e.g. from servers that never carried latestTurn) - // must never read as queued. - expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); - }); - - it("clears once a turn adopts the message or the start fails", () => { - const adopted = { - ...makeShell({ activityAt: QUEUED_AT }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); - - const failed = makeShell({ activityAt: FRESH }); - const failedShell = { - ...failed, - latestUserMessageAt: QUEUED_AT, - session: { - threadId: failed.id, - status: "error" as const, - providerName: "Codex", - runtimeMode: "full-access" as const, - activeTurnId: null, - lastError: "boom", - updatedAt: NOW, - }, - }; - expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); - }); - - it("is quiet without user messages", () => { - expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); - }); - - it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { - // Message timestamps originate on other devices; a clock an hour ahead - // must not hold the queued state for the whole skew. - const skewed = { - latestUserMessageAt: "2026-04-09T13:00:00.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); - // A small negative age (within the grace window) still reads as queued. - const slightlyAhead = { - latestUserMessageAt: "2026-04-09T12:00:30.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); - }); -}); - -describe("canSettle", () => { - it("blocks every state effectiveSettled refuses to classify as settled", () => { - expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), - ).toBe(false); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), - ).toBe(false); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( - false, - ); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( - false, - ); - }); - - it("blocks settling a queued turn start, only within the grace window", () => { - const queued = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: "2026-04-09T12:00:00.000Z", - }; - const justAfter = "2026-04-09T12:00:30.000Z"; - expect(canSettle(queued, { now: justAfter })).toBe(false); - // effectiveSettled must agree: queued work never auto-settles either, - // even with a merged PR. - expect( - effectiveSettled(queued, { - now: justAfter, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - // Past the window the message is a failed/stale start: settleable again. - expect(canSettle(queued, { now: NOW })).toBe(true); - }); - - it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { - // The settle action ran with wall-clock `now` (past the grace window); - // the list partition re-evaluates with a minute-floored `now` that is - // still INSIDE the window. settledAt >= message time proves the server - // already adjudicated this exact message, so the row must not snap back - // to active until the coarser clock catches up. - const messageAt = "2026-04-09T12:00:00.000Z"; - const flooredNow = "2026-04-09T12:01:00.000Z"; - const base = makeShell({ settledOverride: "settled", activityAt: null }); - const settledAfterMessage = { - ...base, - latestUserMessageAt: messageAt, - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); - expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( - true, - ); - - // A message NEWER than settledAt is genuinely new work: still blocked - // until the server's auto-unsettle lands. - const messageAfterSettle = { - ...base, - latestUserMessageAt: "2026-04-09T12:03:00.000Z", - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect( - effectiveSettled(messageAfterSettle, { - now: "2026-04-09T12:03:30.000Z", - autoSettleAfterDays: 3, - }), - ).toBe(false); - }); - - it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { - // Anything canSettle rejects must render as active even when the user - // settled it earlier. - const blocked = makeShell({ - settledOverride: "settled", - activityAt: FRESH, - pending: "user-input", - }); - expect(canSettle(blocked, { now: NOW })).toBe(false); - expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - }); -}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index eb965c6afe2a..f5209a09e499 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,100 +1,6 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import type { OrchestrationThreadShell } from "@t3tools/contracts"; -export type ChangeRequestStateLike = "open" | "closed" | "merged"; - -/** - * The slice of a change request the settle rules need. `updatedAt` is the - * provider's last-activity timestamp; for a merged/closed request it bounds - * when the terminal state landed. - */ -export interface ChangeRequestSettleSource { - readonly state: ChangeRequestStateLike; - readonly updatedAt?: string | null | undefined; -} - -/** What the settle rules need to know about the thread's own timeline. */ -export type ThreadActivitySource = Pick< - OrchestrationThreadShell, - "createdAt" | "latestUserMessageAt" | "latestTurn" ->; - -/** - * Latest USER-initiated activity: messages and the turn requests they start, - * deliberately not the agent-side started/completed stamps. The settle-on- - * merge anchor uses this so a merge landing mid-turn still settles the - * thread when that turn finishes, while a user re-engaging after the merge - * blocks it for good. Falls back to creation time for untouched threads. - */ -function threadUserActivityAnchorAt(thread: ThreadActivitySource): string { - const messageAt = thread.latestUserMessageAt; - const requestedAt = thread.latestTurn?.requestedAt; - let anchor = thread.createdAt; - for (const candidate of [messageAt, requestedAt]) { - if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) { - anchor = candidate; - } - } - return anchor; -} - -/** - * Returns whether the change request settles the thread immediately. A - * terminal request settles the thread only while it postdates every user- - * initiated event in it: settling on a merge happens ONCE. A request last - * touched before the thread was created is inherited branch history (a new - * thread started at a worktree root whose PR already merged), and one older - * than the user's latest engagement was already adjudicated — re-engaging a - * thread whose PR merged is the user saying the conversation outlived the - * PR. Unknown timestamps keep the old always-settle behavior. - */ -export function changeRequestAutoSettles( - changeRequest: ChangeRequestSettleSource | null | undefined, - options: { - readonly autoSettleOnMerge?: boolean | undefined; - readonly thread?: ThreadActivitySource | null | undefined; - } = {}, -): boolean { - if (changeRequest == null) return false; - const terminal = - changeRequest.state === "closed" || - (changeRequest.state === "merged" && options.autoSettleOnMerge !== false); - if (!terminal) return false; - if (changeRequest.updatedAt == null || options.thread == null) return true; - const updatedAtMs = Date.parse(changeRequest.updatedAt); - const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread)); - // Malformed timestamps fall back to settling, matching servers that never - // report updatedAt. - if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true; - return updatedAtMs >= anchorAtMs; -} - -const DAY_MS = 24 * 60 * 60 * 1_000; - -export function threadLastActivityAt( - shell: Pick, -): string | null { - const candidates = [ - shell.latestUserMessageAt, - shell.latestTurn?.requestedAt, - shell.latestTurn?.startedAt, - shell.latestTurn?.completedAt, - ]; - let latest: string | null = null; - let latestTimestamp = Number.NEGATIVE_INFINITY; - - for (const candidate of candidates) { - if (candidate === null || candidate === undefined) continue; - const timestamp = Date.parse(candidate); - if (timestamp > latestTimestamp) { - latest = candidate; - latestTimestamp = timestamp; - } - } - - return latest; -} - /** * A queued turn start lives for at most this long: session adoption takes * seconds, so a user message still unadopted after the grace window is a @@ -103,6 +9,7 @@ export function threadLastActivityAt( * such threads would be permanently unsettleable. */ export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +const DAY_MS = 24 * 60 * 60 * 1_000; /** * A user message no turn has picked up yet: the turn.start command was @@ -137,28 +44,6 @@ export function hasQueuedTurnStart( ); } -/** - * A thread may be settled only when none of effectiveSettled's activity - * blockers hold. This is deliberately the same list: anything the partition - * refuses to CLASSIFY as settled must also be refused as a settle TARGET. - * The server enforces its own invariants; this client-side twin exists so - * the UI can disable/reject before a round trip. - */ -export function canSettle( - shell: Pick< - OrchestrationThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" - >, - options: { readonly now: string }, -): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - // Queued work is as blocked-on-progress as a live session: settling it - // (or auto-settling it on a closed PR) would hide a just-requested turn. - if (hasQueuedTurnStart(shell, options)) return false; - return true; -} - /** * The snooze lifecycle fields plus everything needed to detect a raised * hand. Snooze is an overlay on the active state: a snoozed thread stays @@ -181,8 +66,7 @@ export type ThreadSnoozeShell = Pick< * the session failed, or a run completed after the snooze was set — the * v1 taste of event-based snooze ("something happened" wakes early). * Raising a hand never clears the server-side snooze fields; it only stops - * the thread from CLASSIFYING as snoozed, exactly like blocked work and - * effectiveSettled. + * the thread from classifying as snoozed. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; @@ -283,79 +167,6 @@ export function threadWokeAt( return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } -/** - * Settled resolution over the server-backed settled lifecycle. Activity - * blockers (pending approval/user-input, a live session, an unadjudicated - * queued turn) are checked first and hold a thread active regardless of any - * override. Past the blockers, the explicit user override (thread.settle / - * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread can auto-settle on a - * merged PR or always on a closed PR (both only while the terminal state is - * the thread's latest event, see changeRequestAutoSettles), or settles on - * inactivity past the window. - * An open PR blocks the inactivity path entirely. The server - * un-settles on real activity (user message, session start, approval/ - * user-input request), so an override never goes stale silently. - */ -export function effectiveSettled( - shell: OrchestrationThreadShell, - options: { - readonly now: string; - readonly autoSettleAfterDays: number | null; - readonly autoSettleOnMerge?: boolean; - readonly changeRequest?: ChangeRequestSettleSource | null; - }, -): boolean { - // Blocked work must remain visible even when a user explicitly settled it. - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - if (hasQueuedTurnStart(shell, { now: options.now })) { - // The queued-turn blocker alone is forgivable: it is clock-derived, and - // list callers pass a coarser `now` than the settle action used. When - // the server already adjudicated the queued message by accepting a - // settle after it (settledAt stamps server accept time), trust that - // ruling — otherwise a settle near the grace boundary leaves the row - // pinned active until the caller's clock ticks over. A message NEWER - // than settledAt is genuinely new work and keeps the block until the - // server's auto-unsettle lands. - const serverAdjudicated = - shell.settledOverride === "settled" && - shell.settledAt !== null && - shell.latestUserMessageAt !== null && - Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt); - if (!serverAdjudicated) return false; - } - if (shell.settledOverride === "settled") return true; - // "active" is the explicit keep-active pin: it suppresses auto-settle - // until real activity clears it server-side. - if (shell.settledOverride === "active") return false; - if ( - changeRequestAutoSettles(options.changeRequest, { - autoSettleOnMerge: options.autoSettleOnMerge, - thread: shell, - }) - ) { - return true; - } - // An open PR is unfinished business regardless of how long the thread has - // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. A configured merge, a close, or an explicit user - // settle resolves it. - if (options.changeRequest?.state === "open") return false; - if (options.autoSettleAfterDays === null) return false; - - const lastActivityAt = threadLastActivityAt(shell); - if (lastActivityAt === null) return false; - - // threadLastActivityAt only returns candidates whose Date.parse beat - // -Infinity, so this parse is a real number; a malformed `now` yields NaN, - // the comparison is false, and the thread stays active (never a surprise - // auto-settle on bad input). - return ( - Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS - ); -} - const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index ab4c7fb0784f..8a62103950bf 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -6,12 +6,14 @@ import { describe, expect, it } from "vite-plus/test"; import { canSnooze, effectiveSnoozed, + hasQueuedTurnStart, resolveSnoozePresets, snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, type ThreadSnoozeShell, } from "./threadSettled.ts"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; const NOW = "2026-04-10T12:00:00.000Z"; const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; @@ -61,6 +63,15 @@ function makeShell(input: { }; } +type QueuedTurnShell = Pick< + OrchestrationThreadShell, + "latestUserMessageAt" | "latestTurn" | "session" +>; + +function makeQueuedTurnShell(overrides: Partial = {}): QueuedTurnShell { + return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides }; +} + describe("effectiveSnoozed", () => { it("hides a thread whose wake time is in the future", () => { expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true); @@ -202,6 +213,55 @@ describe("canSnooze", () => { }); }); +describe("hasQueuedTurnStart", () => { + it("expires queued state after two minutes", () => { + const thread = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T11:57:59.000Z", + }); + expect(hasQueuedTurnStart(thread, { now: NOW })).toBe(false); + }); + + it("clears queued state when a turn adopts the message or the session fails", () => { + const messageAt = "2026-04-10T11:59:00.000Z"; + const adopted = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + latestTurn: { + turnId: TurnId.make("turn-adopted"), + state: "running", + requestedAt: messageAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }); + const failed = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + session: { + threadId: ThreadId.make("thread-failed"), + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "failed", + updatedAt: NOW, + }, + }); + expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false); + }); + + it("bounds future client clock skew", () => { + const farAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:03:00.000Z", + }); + const slightlyAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:01:00.000Z", + }); + expect(hasQueuedTurnStart(farAhead, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(slightlyAhead, { now: NOW })).toBe(true); + }); +}); + describe("threadWokeAt", () => { it("is null for never-snoozed and still-snoozed threads", () => { expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 805e9da23ebb..08e82599020a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -63,6 +63,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** Server evaluates merge and inactivity settlement without a client. */ + threadAutoSettlement: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7ca7175ad175..bbfaa1b595c2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -772,6 +772,13 @@ const ThreadSettleCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadAutoSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.auto-settle"), + commandId: CommandId, + threadId: ThreadId, + snapshotSequence: NonNegativeInt, +}); + const ThreadUnsettleCommand = Schema.Struct({ type: Schema.Literal("thread.unsettle"), commandId: CommandId, @@ -1107,6 +1114,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ }); const InternalOrchestrationCommand = Schema.Union([ + ThreadAutoSettleCommand, ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e94a66435b1f..fa1c702b1900 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -137,11 +137,8 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with automatic merge and inactivity settling", () => { - const settings = decodeClientSettings({}); - expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarAutoSettleAfterDays).toBe(3); - expect(settings.sidebarAutoSettleOnMerge).toBe(true); + it("defaults to the current sidebar", () => { + expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -166,25 +163,33 @@ describe("ClientSettings sidebar", () => { expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true); expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow(); }); +}); - it("allows auto-settle by inactivity to be disabled", () => { - expect( - decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, - ).toBeNull(); +describe("ServerSettings thread settlement", () => { + it("defaults merge settlement on and inactivity settlement to three days", () => { + const settings = decodeServerSettings({}); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); - it("allows auto-settle on merge to be disabled", () => { - expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( - false, - ); + it("allows both automatic rules to be disabled", () => { expect( - decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, - ).toBe(false); + decodeServerSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); + expect( + decodeServerSettingsPatch({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); }); it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { - expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); - expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b721ce976d1d..25577b752c63 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -253,10 +253,6 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), - ), - sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -658,6 +654,10 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), + sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New // consumers should resolve `backgroundActivity` instead. @@ -883,6 +883,8 @@ export const ServerSettingsPatch = Schema.Struct({ enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), @@ -976,8 +978,6 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), - sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), - sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 8b033de48247086c1b6c6968ce7cf33b358a1ec5 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 12:44:36 +0200 Subject: [PATCH 37/42] fix(clients): dedupe skills in composer menus (#8043) --- .../threads/use-composer-command-menu.ts | 10 ++- apps/web/src/providerSkillSearch.test.ts | 13 ++++ apps/web/src/providerSkillSearch.ts | 7 +- .../client-runtime/src/providerSkills.test.ts | 69 +++++++++++++++++++ packages/client-runtime/src/providerSkills.ts | 18 ++++- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1043e5e39aad..ac703a547fde 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -9,6 +9,10 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { + dedupeProviderSkillsByName, + getProviderSkillsForSlashMenu, +} from "@t3tools/client-runtime/providerSkills"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; @@ -130,7 +134,7 @@ export function useComposerCommandMenu({ }); } - const skillItems = (selectedProviderStatus?.skills ?? []) + const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true) .filter((skill) => matchesSlashSkillQuery(skill, q)) .map((skill) => ({ id: `skill:${skill.name}`, @@ -144,7 +148,9 @@ export function useComposerCommandMenu({ } if (trigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled); + const enabledSkills = dedupeProviderSkillsByName( + (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled), + ); const normalizedQuery = normalizeSearchQuery(trigger.query, { trimLeadingPattern: /^\$+/, }); diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index dbd0304c5e0b..133f65218756 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -69,4 +69,17 @@ describe("searchProviderSkills", () => { "browser", ]); }); + + it("returns the first enabled definition for each skill name", () => { + const skills = [ + makeSkill({ name: "branch-audit", path: "/Users/matt/.codex/skills/branch-audit/SKILL.md" }), + makeSkill({ name: "browser" }), + makeSkill({ name: "branch-audit", path: "/Users/matt/.agents/skills/branch-audit/SKILL.md" }), + ]; + + expect(searchProviderSkills(skills, "").map((skill) => skill.path)).toEqual([ + "/Users/matt/.codex/skills/branch-audit/SKILL.md", + "/tmp/browser/SKILL.md", + ]); + }); }); diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index c12bec1327a4..964907365fb9 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -1,5 +1,8 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { + dedupeProviderSkillsByName, + formatProviderSkillDisplayName, +} from "@t3tools/client-runtime/providerSkills"; import { insertRankedSearchResult, normalizeSearchQuery, @@ -70,7 +73,7 @@ export function searchProviderSkills( query: string, limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { - const enabledSkills = skills.filter((skill) => skill.enabled); + const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)); const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); if (!normalizedQuery) { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 08c79f5d8718..c49b0b682293 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + dedupeProviderSkillsByName, formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, @@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => { }); }); +describe("dedupeProviderSkillsByName", () => { + it("keeps the first resolved skill and preserves unrelated skill order", () => { + const firstSkill = { + name: "branch-audit", + path: "/Users/matt/.codex/skills/branch-audit/SKILL.md", + enabled: true, + }; + const otherSkill = { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }; + const duplicateSkill = { + name: "Branch-Audit", + path: "/Users/matt/.agents/skills/branch-audit/SKILL.md", + enabled: true, + }; + + expect(dedupeProviderSkillsByName([firstSkill, otherSkill, duplicateSkill])).toEqual([ + firstSkill, + otherSkill, + ]); + }); +}); + describe("getProviderSkillsForSlashMenu", () => { it("keeps the skill alias when the provider also exposes it as a slash command", () => { const askMatt = { @@ -37,6 +63,49 @@ describe("getProviderSkillsForSlashMenu", () => { "ask-matt", ]); }); + + it("shows one row when enabled skills share a name", () => { + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: true, + }, + { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }, + { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([ + "babysit-pr", + "browser", + ]); + }); + + it("keeps an enabled skill when a disabled duplicate appears first", () => { + const enabledSkill = { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }; + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: false, + }, + enabledSkill, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true)).toEqual([enabledSkill]); + }); }); describe("getProviderSlashCommandsForSlashMenu", () => { diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index faab12799ba9..653c1dd85474 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName( return titleCaseWords(skill.name); } +export function dedupeProviderSkillsByName( + skills: ReadonlyArray, +): ServerProviderSkill[] { + const seenNames = new Set(); + return skills.filter((skill) => { + const normalizedName = skill.name.trim().toLowerCase(); + if (seenNames.has(normalizedName)) { + return false; + } + seenNames.add(normalizedName); + return true; + }); +} + export function getProviderSkillsForSlashMenu( skills: ReadonlyArray, showSkillsInSlashMenu: boolean, ): ServerProviderSkill[] { - return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : []; + return showSkillsInSlashMenu + ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)) + : []; } export function getProviderSlashCommandsForSlashMenu( From 62d39bf00d5ddd83a9b36a81321fe9aa4d2502bb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 04:05:55 -0700 Subject: [PATCH 38/42] fix(server): stop OpenCode child sessions (#9005) --- .../provider/Layers/OpenCodeAdapter.test.ts | 341 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 195 ++++++++-- docs/user/providers-opencode.md | 9 + 3 files changed, 497 insertions(+), 48 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 0ba4b2d1ee3d..d297360e6d34 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -69,6 +69,11 @@ const runtimeMock = { abortImplementation: null as | ((sessionID: string, signal?: AbortSignal) => Promise) | null, + sessionChildrenCalls: [] as string[], + sessionChildrenById: new Map>(), + sessionChildrenImplementation: null as + | ((sessionID: string) => Promise>) + | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, @@ -116,6 +121,9 @@ const runtimeMock = { this.state.abortCalls.length = 0; this.state.abortSignals.length = 0; this.state.abortImplementation = null; + this.state.sessionChildrenCalls.length = 0; + this.state.sessionChildrenById.clear(); + this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; @@ -252,6 +260,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); }, + children: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionChildrenCalls.push(sessionID); + return { + data: runtimeMock.state.sessionChildrenImplementation + ? await runtimeMock.state.sessionChildrenImplementation(sessionID) + : (runtimeMock.state.sessionChildrenById.get(sessionID) ?? []), + }; + }, status: async () => { runtimeMock.state.sessionStatusCalls += 1; if (runtimeMock.state.sessionStatusImplementation) { @@ -1129,6 +1145,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("stops a configured-server session without trying to own server lifecycle", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_stop_child" }]); + runtimeMock.state.sessionChildrenById.set("ses_stop_child", [{ id: "ses_stop_grandchild" }]); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-opencode"), @@ -1138,10 +1157,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(asThreadId("thread-opencode")); NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual( - runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), - true, - ); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, [ + rootSessionId, + "ses_stop_child", + "ses_stop_grandchild", + ]); }), ); @@ -2999,6 +3019,262 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("stops the full OpenCode child tree before it completes the interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-tree"); + const parentAbortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + const parentAbortStarted = promiseWithResolvers(); + const parentAbortRelease = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [parentAbortEvent.promise, markerEvent.promise]; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_child_a" }, + { id: "ses_child_b" }, + ]); + runtimeMock.state.sessionChildrenById.set("ses_child_a", [{ id: "ses_grandchild" }]); + runtimeMock.state.sessionChildrenById.set("ses_unrelated", [{ id: "ses_unrelated_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + parentAbortStarted.resolve(undefined); + await parentAbortRelease.promise; + } + if (sessionID === "ses_child_a") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } + }; + + const markerFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.metadata.updated", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => parentAbortStarted.promise); + runtimeMock.state.sessionChildrenById.get(rootSessionId)?.push({ id: "ses_late_child" }); + parentAbortEvent.resolve({ + id: "evt-parent-aborted", + type: "session.error", + properties: { + sessionID: rootSessionId, + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-parent-abort", + type: "session.updated", + properties: { info: { id: rootSessionId, title: "Parent abort received" } }, + }); + yield* Fiber.join(markerFiber); + + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + yield* Effect.promise(() => childAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated"), false); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated_child"), false); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "running"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, turn.turnId); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after every child stops", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + childAbortRelease.resolve(undefined); + parentAbortRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.equal(result._tag, "Success"); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + NodeAssert.equal(runtimeMock.state.abortCalls[0], rootSessionId); + NodeAssert.deepEqual( + new Set(runtimeMock.state.abortCalls.slice(1)), + new Set(["ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + NodeAssert.deepEqual( + new Set(runtimeMock.state.sessionChildrenCalls), + new Set([rootSessionId, "ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, nextTurn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("limits SDK requests across the full OpenCode child tree", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-request-limit"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const requestRelease = promiseWithResolvers(); + const limitReached = promiseWithResolvers(); + let inFlight = 0; + let maxInFlight = 0; + const holdRequest = async (result: T): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + if (inFlight === 8) { + limitReached.resolve(undefined); + } + await requestRelease.promise; + inFlight -= 1; + return result; + }; + + const children = Array.from({ length: 8 }, (_, index) => ({ id: `ses_child_${index}` })); + runtimeMock.state.sessionChildrenById.set(rootSessionId, children); + for (const child of children.slice(1)) { + runtimeMock.state.sessionChildrenById.set( + child.id, + Array.from({ length: 8 }, (_, index) => ({ id: `${child.id}_nested_${index}` })), + ); + } + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID.includes("_nested_")) { + await holdRequest(undefined); + } + }; + runtimeMock.state.sessionChildrenImplementation = async (sessionID) => { + if (sessionID === "ses_child_0") { + return await holdRequest([]); + } + return runtimeMock.state.sessionChildrenById.get(sessionID) ?? []; + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run a nested child tree", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => limitReached.promise); + yield* Effect.yieldNow; + + NodeAssert.equal(inFlight, 8); + NodeAssert.equal(maxInFlight, 8); + + requestRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + runtimeMock.state.abortImplementation = null; + runtimeMock.state.sessionChildrenImplementation = null; + runtimeMock.state.sessionChildrenById.clear(); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("attempts every child abort and fails the interrupt when one child abort fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-failure"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const failingChildStarted = promiseWithResolvers(); + const failingChildRelease = promiseWithResolvers(); + const siblingAbortStarted = promiseWithResolvers(); + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_failing_child" }, + { id: "ses_surviving_sibling" }, + ]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === "ses_failing_child") { + failingChildStarted.resolve(undefined); + await failingChildRelease.promise; + throw new Error("child abort failed"); + } + if (sessionID === "ses_surviving_sibling") { + siblingAbortStarted.resolve(undefined); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => failingChildStarted.promise); + yield* Effect.promise(() => siblingAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + failingChildRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + + NodeAssert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(result.failure.detail, "child abort failed"); + } + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_failing_child"), true); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_surviving_sibling"), true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, turn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + it.effect("keeps an idle event from completing a turn while its abort request is pending", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -4278,11 +4554,20 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const threadId = asThreadId("thread-interrupt-provider-error"); const errorEvent = promiseWithResolvers(); const abortStarted = promiseWithResolvers(); - const abortRelease = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; runtimeMock.state.subscribedEvents = [errorEvent.promise]; - runtimeMock.state.abortImplementation = async () => { - abortStarted.resolve(undefined); - await abortRelease.promise; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_error_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + abortStarted.resolve(undefined); + await new Promise(() => {}); + } + if (sessionID === "ses_error_child") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } }; const eventsFiber = yield* adapter.streamEvents.pipe( @@ -4313,16 +4598,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { id: "evt-provider-error-after-stop", type: "session.error", properties: { - sessionID: "http://127.0.0.1:9999/session", + sessionID: rootSessionId, error: { name: "APIError", data: { message: "Upstream failed", isRetryable: false }, }, }, }); - yield* Effect.yieldNow; - abortRelease.resolve(undefined); - yield* Fiber.join(interruptFiber); + yield* Effect.promise(() => childAbortStarted.promise); const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); NodeAssert.deepEqual( @@ -4341,7 +4624,41 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { failed?.type === "turn.completed" ? failed.payload.state : undefined, "failed", ); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "error"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, undefined); + + const secondInterruptFiber = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after child cleanup", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal( + runtimeMock.state.abortCalls.filter((sessionID) => sessionID === rootSessionId).length, + 1, + ); + NodeAssert.equal(secondInterruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(nextTurnFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + childAbortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + yield* Fiber.join(secondInterruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + + runtimeMock.state.abortImplementation = null; yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 606dcde6ce9e..d0b4f0de78ce 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -192,8 +192,10 @@ const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessio interface OpenCodeCancellation { readonly turnId: TurnId | undefined; + readonly acknowledgment: Deferred.Deferred; readonly completion: Deferred.Deferred; acknowledged?: boolean; + turnSettled?: boolean; deferredIdleEvent?: OpenCodeSessionStatusEvent; } @@ -702,10 +704,88 @@ const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellati ).pipe(Effect.ignore); }); -const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) => - runOpenCodeSdk("session.abort", (signal) => +const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* ( + context: OpenCodeSessionContext, +) { + const visited = new Set([context.openCodeSessionId]); + const requestSemaphore = Semaphore.makeUnsafe(8); + + const visit = ( + sessionId: string, + abortSession: boolean, + ): Effect.Effect => + Effect.gen(function* () { + let firstFailure: OpenCodeRuntimeError | undefined; + if (abortSession) { + const abortResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (abortResult._tag === "Failure") { + firstFailure = abortResult.failure; + } + } + + const childrenResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.children", (signal) => + context.client.session.children({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (childrenResult._tag === "Failure") { + return firstFailure ?? childrenResult.failure; + } + + const children = childrenResult.success?.data ?? []; + const newChildren = children.filter((child) => { + if (visited.has(child.id)) { + return false; + } + visited.add(child.id); + return true; + }); + const childFailures = yield* Effect.forEach(newChildren, (child) => visit(child.id, true), { + concurrency: 8, + }); + firstFailure ??= childFailures.find((failure) => failure !== undefined); + return firstFailure; + }); + + const firstFailure = yield* visit(context.openCodeSessionId, false); + if (firstFailure) { + return yield* firstFailure; + } +}); + +const abortOpenCodeSessionForTeardown = Effect.fn("abortOpenCodeSessionForTeardown")(function* ( + context: OpenCodeSessionContext, +) { + // Stop the parent before the snapshot so it cannot add another child after + // the adapter reads the tree. + yield* runOpenCodeSdk("session.abort", (signal) => context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })); + yield* abortOpenCodeDescendants(context).pipe( + Effect.timeout("1 second"), + Effect.ignore({ log: true }), + ); +}); const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* ( context: OpenCodeSessionContext, @@ -2158,13 +2238,12 @@ export function makeOpenCodeAdapter( if (isOpenCodeAbortError(event.properties.error)) { if (cancellation !== undefined && cancellation.turnId === undefined) { cancellation.acknowledged = true; - context.cancellation = undefined; - context.reconcileIdleStatus = true; - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); break; } if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { - yield* interruptOpenCodeTurn(context, activeTurnId, event); + cancellation.acknowledged = true; + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); break; } if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) { @@ -2172,9 +2251,13 @@ export function makeOpenCodeAdapter( } } yield* cancelIdleReconciliation(context); - if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { - context.cancellation = undefined; - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + const terminalCancellation = + activeTurnId !== undefined && cancellation?.turnId === activeTurnId + ? cancellation + : undefined; + if (terminalCancellation) { + terminalCancellation.turnSettled = true; + terminalCancellation.acknowledged = true; } context.activeTurnId = undefined; context.activeAgent = undefined; @@ -2214,6 +2297,11 @@ export function makeOpenCodeAdapter( detail: event.properties.error, }, }); + if (terminalCancellation) { + yield* Deferred.succeed(terminalCancellation.acknowledgment, undefined).pipe( + Effect.ignore, + ); + } break; } @@ -2909,14 +2997,12 @@ export function makeOpenCodeAdapter( return; } const existingCancellation = context.cancellation; - if ( - existingCancellation !== undefined && - existingCancellation.turnId === interruptedTurnId - ) { + if (existingCancellation !== undefined) { return yield* Deferred.await(existingCancellation.completion); } const cancellation: OpenCodeCancellation = { turnId: interruptedTurnId, + acknowledgment: Deferred.makeUnsafe(), completion: Deferred.makeUnsafe(), }; context.cancellation = cancellation; @@ -2929,10 +3015,11 @@ export function makeOpenCodeAdapter( yield* Deferred.await(promptAdmission.submissionSettled); } - const abortOutcome = yield* Effect.raceFirst( + const parentAbortOutcome = yield* Effect.raceFirst( runOpenCodeSdk("session.abort", (signal) => context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), ).pipe( + Effect.asVoid, Effect.timeout("10 seconds"), Effect.catchTags({ OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), @@ -2949,33 +3036,67 @@ export function makeOpenCodeAdapter( Effect.exit, Effect.map((exit) => ({ source: "request" as const, exit })), ), + Effect.raceFirst( + Deferred.await(cancellation.acknowledgment).pipe( + Effect.map(() => ({ source: "acknowledgment" as const })), + ), + Deferred.await(cancellation.completion).pipe( + Effect.exit, + Effect.map((exit) => ({ source: "completion" as const, exit })), + ), + ), + ); + if (parentAbortOutcome.source === "completion") { + return Exit.isFailure(parentAbortOutcome.exit) + ? yield* Effect.failCause(parentAbortOutcome.exit.cause) + : undefined; + } + const parentAbortExit = + parentAbortOutcome.source === "request" ? parentAbortOutcome.exit : Exit.void; + + const descendantAbortOutcome = yield* Effect.raceFirst( + abortOpenCodeDescendants(context).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: "OpenCode child session cleanup did not complete within 10 seconds.", + cause, + }), + ), + }), + Effect.exit, + Effect.map((exit) => ({ source: "request" as const, exit })), + ), Deferred.await(cancellation.completion).pipe( Effect.exit, - Effect.map((exit) => ({ source: "event" as const, exit })), + Effect.map((exit) => ({ source: "completion" as const, exit })), ), ); - if (abortOutcome.source === "event") { - return Exit.isFailure(abortOutcome.exit) - ? yield* Effect.failCause(abortOutcome.exit.cause) + if (descendantAbortOutcome.source === "completion") { + return Exit.isFailure(descendantAbortOutcome.exit) + ? yield* Effect.failCause(descendantAbortOutcome.exit.cause) : undefined; } - const abortExit = abortOutcome.exit; - if (Exit.isFailure(abortExit)) { - if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) { - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); - return; - } - if (cancellation.turnId === undefined && cancellation.acknowledged) { - if (context.cancellation === cancellation) { - context.cancellation = undefined; - context.reconcileIdleStatus = true; - } - yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); - return; - } + + const parentAbortFailed = Exit.isFailure(parentAbortExit) && !cancellation.acknowledged; + const failedExit = parentAbortFailed + ? parentAbortExit + : Exit.isFailure(descendantAbortOutcome.exit) + ? descendantAbortOutcome.exit + : undefined; + if (failedExit !== undefined && Exit.isFailure(failedExit)) { if (context.cancellation === cancellation) { context.cancellation = undefined; - if (cancellation.turnId !== undefined && cancellation.deferredIdleEvent) { + if ( + parentAbortFailed && + cancellation.turnId !== undefined && + cancellation.deferredIdleEvent + ) { yield* scheduleIdleReconciliation( context, cancellation.turnId, @@ -2983,12 +3104,14 @@ export function makeOpenCodeAdapter( ); } } - yield* Deferred.done(cancellation.completion, abortExit).pipe(Effect.ignore); - return yield* Effect.failCause(abortExit.cause); + yield* Deferred.done(cancellation.completion, failedExit).pipe(Effect.ignore); + return yield* Effect.failCause(failedExit.cause); } if (context.cancellation === cancellation) { - if (cancellation.turnId !== undefined) { + if (cancellation.turnSettled) { + context.cancellation = undefined; + } else if (cancellation.turnId !== undefined) { yield* interruptOpenCodeTurn(context, cancellation.turnId); } else { context.cancellation = undefined; diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 09404bd34c06..a066b038833d 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -17,6 +17,15 @@ With a server URL, T3 Code connects to that external server and uses only the pa provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server. OpenCode uses this password for HTTP Basic authentication. +## Stop a turn + +When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions. +T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It +does not stop unrelated OpenCode sessions. + +Stop reports an error if OpenCode cannot list or stop a child session. When T3 Code closes an +OpenCode session, it also tries to stop the child sessions, but this teardown is best effort. + ## Refresh the model list T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its From 3c73fa7ce02b7ee6b2904ec58e47c06262aebf5d Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 13:07:27 +0200 Subject: [PATCH 39/42] perf(web): defer pull request line stats until visible (#6471) Co-authored-by: Theo Browne --- .../components/pullRequest/PullRequestRow.tsx | 9 +- .../pullRequest/pullRequestList.logic.test.ts | 196 ++++++++++++++ .../pullRequest/pullRequestList.logic.ts | 150 +++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 243 ++++++++++++++---- apps/web/src/state/pullRequests.ts | 24 +- 5 files changed, 561 insertions(+), 61 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index b6b96ab7f55e..2284144d7fd1 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,5 +1,5 @@ import { SearchIcon } from "lucide-react"; -import { memo } from "react"; +import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; @@ -40,6 +40,8 @@ function PullRequestRowImpl({ showProvider, environmentLabel, matchedElsewhere, + statsKey, + statsRef, onSelect, }: { entry: EnvironmentPullRequestEntry; @@ -54,11 +56,16 @@ function PullRequestRowImpl({ * commit message. Saying so is the difference between a result and an apparently random row. */ matchedElsewhere?: boolean; + /** Used by the list's shared visibility observer to defer optional line-count reads. */ + statsKey?: string; + statsRef?: RefCallback; onSelect: (entry: EnvironmentPullRequestEntry) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return (

) : null} - {group.entries.map((entry) => ( - 1 && - environmentLabels.get(entry.environmentId) !== undefined - ? { environmentLabel: environmentLabels.get(entry.environmentId)! } - : {})} - // Ten is the floor the ranking gives a row whose own fields say nothing - // about the search: the host matched something this row cannot show. - matchedElsewhere={ - typedParsed.text.length > 0 && - scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE - } - selected={ - selected?.environmentId === entry.environmentId && - selected.repository === entry.repository && - selected.number === entry.number - } - onSelect={selectEntry} - /> - ))} + {group.entries.map((entry) => { + const entryKey = pullRequestEntryKey(entry); + return ( + 1 && + environmentLabels.get(entry.environmentId) !== undefined + ? { environmentLabel: environmentLabels.get(entry.environmentId)! } + : {})} + // Ten is the floor the ranking gives a row whose own fields say nothing + // about the search: the host matched something this row cannot show. + matchedElsewhere={ + typedParsed.text.length > 0 && + scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE + } + selected={ + selected?.environmentId === entry.environmentId && + selected.repository === entry.repository && + selected.number === entry.number + } + onSelect={selectEntry} + /> + ); + })}
))}
@@ -1648,6 +1785,7 @@ function PullRequestsRouteView() { pullRequestsSupported && !rightPanelState.isOpen ? openPanelControls : null, rightPanelOpen: rightPanelState.isOpen, listBody, + scrollRef, }; const activateSurface = (surface: PullRequestSurface) => { @@ -1932,6 +2070,7 @@ function PullRequestsColumn({ titlebarControls, rightPanelOpen, listBody, + scrollRef, }: { refreshing: boolean; onRefresh: () => void; @@ -1950,8 +2089,8 @@ function PullRequestsColumn({ titlebarControls: ReactNode; rightPanelOpen: boolean; listBody: ReactNode; + scrollRef: RefObject; }) { - const scrollRef = useRef(null); const markerRef = useRef(null); const [condensed, setCondensed] = useState(false); useEffect(() => { diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 99bbddd6e388..939c4f3aea2d 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -31,7 +31,7 @@ export interface EnvironmentQueryTarget { } interface MergedEnvironmentQueryView
{ - /** One entry per environment that has answered, in the order the targets were given. */ + /** One entry per query target that has answered, in the order the targets were given. */ readonly values: ReadonlyArray; /** The first environment that failed. Others may still have answered — this is not fatal. */ readonly error: string | null; @@ -78,11 +78,16 @@ function createMergedEnvironmentQuery( return function useMergedQuery(targets: ReadonlyArray>) { const key = JSON.stringify(targets); const view = useAtomValue(targets.length === 0 ? empty : family(key)); - const refresh = useCallback(() => { - for (const target of JSON.parse(key) as ReadonlyArray>) { - appAtomRegistry.refresh(atomFor(target)); - } - }, [key]); + const refresh = useCallback( + (override?: ReadonlyArray>) => { + const refreshTargets = + override ?? (JSON.parse(key) as ReadonlyArray>); + for (const target of refreshTargets) { + appAtomRegistry.refresh(atomFor(target)); + } + }, + [key], + ); return { ...view, refresh }; }; } @@ -118,7 +123,10 @@ export function usePullRequestListStats( targets: ReadonlyArray>, ): { readonly stats: ReadonlyArray | null; - readonly refresh: () => void; + readonly isPending: boolean; + readonly refresh: ( + targets?: ReadonlyArray>, + ) => void; } { const query = usePullRequestStatsQuery(targets); const stats = useMemo( @@ -130,5 +138,5 @@ export function usePullRequestListStats( ), [query.values], ); - return { stats, refresh: query.refresh }; + return { stats, isPending: query.isPending, refresh: query.refresh }; } From e86604d3372acccd9f6a33a2c4ae46f4e2685541 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 04:09:06 -0700 Subject: [PATCH 40/42] perf(server): skip full-message reads while streaming (#9032) --- .../Layers/ProjectionPipeline.test.ts | 59 +++++++++++++++-- .../Layers/ProjectionPipeline.ts | 33 +++++++--- .../Layers/ProjectionThreadMessages.test.ts | 65 +++++++++++++++++++ .../Layers/ProjectionThreadMessages.ts | 53 +++++++++++++++ .../Services/ProjectionThreadMessages.ts | 12 ++++ 5 files changed, 205 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index b8f65a54b92e..32551643b0d4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1462,6 +1462,8 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; + const streamingAt = "2026-01-01T00:00:01.000Z"; + const completedAt = "2026-01-01T00:00:02.000Z"; yield* eventStore.append({ type: "project.created", @@ -1526,7 +1528,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { role: "assistant", text: "hello", turnId: null, - streaming: false, + streaming: true, createdAt: now, updatedAt: now, }, @@ -1539,7 +1541,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { eventId: EventId.make("evt-a4"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-a"), - occurredAt: now, + occurredAt: streamingAt, commandId: CommandId.make("cmd-a4"), causationEventId: null, correlationId: CorrelationId.make("cmd-a4"), @@ -1551,18 +1553,61 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { text: " world", turnId: null, streaming: true, - createdAt: now, - updatedAt: now, + createdAt: streamingAt, + updatedAt: streamingAt, }, }); yield* projectionPipeline.bootstrap; yield* projectionPipeline.bootstrap; - const messageRows = yield* sql<{ readonly text: string }>` - SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-a5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-a"), + occurredAt: completedAt, + commandId: CommandId.make("cmd-a5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-a5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-a"), + messageId: MessageId.make("message-a"), + role: "assistant", + text: "", + turnId: null, + streaming: false, + createdAt: completedAt, + updatedAt: completedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; + + const messageRows = yield* sql<{ + readonly text: string; + readonly isStreaming: number; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = 'message-a' `; - assert.deepEqual(messageRows, [{ text: "hello world" }]); + assert.deepEqual(messageRows, [ + { + text: "hello world", + isStreaming: 0, + createdAt: now, + updatedAt: completedAt, + }, + ]); const stateRows = yield* sql<{ readonly projector: string; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 672336816727..22daeee69365 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1008,21 +1008,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; case "thread.message-sent": { + if (event.payload.streaming) { + const attachments = + event.payload.attachments !== undefined + ? yield* materializeAttachmentsForProjection({ + attachments: event.payload.attachments, + }) + : undefined; + yield* projectionThreadMessageRepository.appendStreaming({ + messageId: event.payload.messageId, + threadId: event.payload.threadId, + turnId: event.payload.turnId, + role: event.payload.role, + text: event.payload.text, + ...(attachments !== undefined ? { attachments: [...attachments] } : {}), + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, }); const previousMessage = Option.getOrUndefined(existingMessage); const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + onSome: (message) => + event.payload.text.length === 0 ? message.text : event.payload.text, }); const nextAttachments = event.payload.attachments !== undefined @@ -1037,7 +1050,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), - isStreaming: event.payload.streaming, + isStreaming: false, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577..30e0f42cab89 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,71 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("appends streaming text and applies attachment updates", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-streaming-append"); + const messageId = MessageId.make("message-streaming-append"); + const createdAt = "2026-02-28T19:05:00.000Z"; + const attachments = [ + { + type: "image" as const, + id: "thread-streaming-append-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]; + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "hello", + attachments, + createdAt, + updatedAt: createdAt, + }); + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: " world", + createdAt: "2026-02-28T19:05:01.000Z", + updatedAt: "2026-02-28T19:05:01.000Z", + }); + + const rowWithPreservedAttachments = yield* repository.getByMessageId({ messageId }); + assert.equal(rowWithPreservedAttachments._tag, "Some"); + if (rowWithPreservedAttachments._tag === "Some") { + assert.deepEqual(rowWithPreservedAttachments.value.attachments, attachments); + } + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "", + attachments: [], + createdAt: "2026-02-28T19:05:02.000Z", + updatedAt: "2026-02-28T19:05:02.000Z", + }); + + const row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.text, "hello world"); + assert.deepEqual(row.value.attachments, []); + assert.equal(row.value.createdAt, createdAt); + assert.equal(row.value.updatedAt, "2026-02-28T19:05:02.000Z"); + assert.isTrue(row.value.isStreaming); + } + }), + ); + it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..85e854dc6606 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -9,6 +9,7 @@ import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { + AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, @@ -95,6 +96,50 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { }, }); + const appendStreamingProjectionThreadMessageRow = SqlSchema.void({ + Request: AppendStreamingProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + ${nextAttachmentsJson}, + 1, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = projection_thread_messages.text || excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), + is_streaming = 1, + updated_at = excluded.updated_at + `; + }, + }); + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ Request: GetProjectionThreadMessageInput, Result: ProjectionThreadMessageDbRowSchema, @@ -151,6 +196,13 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); + const appendStreaming: ProjectionThreadMessageRepositoryShape["appendStreaming"] = (row) => + appendStreamingProjectionThreadMessageRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.appendStreaming:query"), + ), + ); + const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => getProjectionThreadMessageRow(input).pipe( Effect.mapError( @@ -176,6 +228,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, + appendStreaming, getByMessageId, listByThreadId, deleteByThreadId, diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..17b659a2f8da 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import * as Struct from "effect/Struct"; import type * as Option from "effect/Option"; import type * as Effect from "effect/Effect"; @@ -34,6 +35,12 @@ export const ProjectionThreadMessage = Schema.Struct({ }); export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; +export const AppendStreamingProjectionThreadMessage = Schema.Struct( + Struct.omit(ProjectionThreadMessage.fields, ["isStreaming"]), +); +export type AppendStreamingProjectionThreadMessage = + typeof AppendStreamingProjectionThreadMessage.Type; + export const ListProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -62,6 +69,11 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; + /** Insert a streaming message or append text to its existing row. */ + readonly appendStreaming: ( + message: AppendStreamingProjectionThreadMessage, + ) => Effect.Effect; + /** * Read a projected thread message by id. */ From b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 13:23:50 +0200 Subject: [PATCH 41/42] perf(client-runtime): halve server config bootstrap traffic (#8367) Reuse one server config subscription for session bootstrap and live updates. Preserve environment theme opt-in, replay, deletion, slow subscriber recovery, and config stream failure handling. Co-authored-by: Adamulek123 --- apps/web/src/cloud/linkEnvironment.test.ts | 1 + apps/web/src/connection/runtime.ts | 5 +- .../client-runtime/src/connection/layer.ts | 53 +- .../src/connection/registry.test.ts | 2 + .../src/connection/supervisor.test.ts | 1 + .../src/operations/commands.test.ts | 1 + .../client-runtime/src/rpc/client.test.ts | 36 + packages/client-runtime/src/rpc/client.ts | 6 +- .../client-runtime/src/rpc/session.test.ts | 698 +++++++++++++++++- packages/client-runtime/src/rpc/session.ts | 219 +++++- .../src/state/pullRequests.test.ts | 1 + .../client-runtime/src/state/server.test.ts | 3 +- packages/client-runtime/src/state/server.ts | 93 +-- .../src/state/serverConfigProjection.ts | 79 ++ .../src/state/shell-sync.test.ts | 1 + .../src/state/sourceControl.test.ts | 1 + .../src/state/threads-pagination.test.ts | 1 + .../src/state/threads-sync.test.ts | 1 + packages/client-runtime/src/state/vcs.test.ts | 1 + .../src/state/vcsAction.test.ts | 1 + 20 files changed, 1053 insertions(+), 151 deletions(-) create mode 100644 packages/client-runtime/src/state/serverConfigProjection.ts diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 38e205beabbb..7ae5e7ed03a9 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -91,6 +91,7 @@ function registryLayer(options?: { const session: RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index b63d01999036..06c8bf0ccfed 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -30,7 +30,10 @@ type ConnectionLayerSource = | typeof backgroundActivityObserverLayer | typeof backgroundActivityReporterLayer; -const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( +const providedClientConnectionLayer = Layer.merge( + Connection.layerWithOptions({ environmentThemes: true }), + snapshotLoaderLayer, +).pipe( Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 798ec01e2f0d..7927151e5d42 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -15,30 +15,29 @@ const resolverLayer = ConnectionResolver.layer.pipe( Layer.provide(RemoteEnvironmentAuthorization.layer), ); -const driverLayer = ConnectionDriver.layer.pipe( - Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), -); - -const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); - -const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); - -const connectionServicesLayer = Layer.mergeAll( - registryLayer, - RelayEnvironmentDiscovery.layer, - onboardingLayer, -); - -const connectionStartupLayer = Layer.effectDiscard( - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; - yield* registry.start; - yield* platformSource.registrations.pipe( - Stream.runForEach(registry.reconcilePlatform), - Effect.forkScoped, - ); - }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), -); - -export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); +export function layerWithOptions(options: RpcSession.RpcSessionOptions) { + const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layerWithOptions(options))), + ); + const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); + const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); + const connectionServicesLayer = Layer.mergeAll( + registryLayer, + RelayEnvironmentDiscovery.layer, + onboardingLayer, + ); + const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.reconcilePlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), + ); + return connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); +} + +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 2d0ce41daf7c..4dbbfe45fe00 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -356,6 +356,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( Effect.succeed({ client: {} as RpcSession.RpcSession["client"], initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + subscribeServerConfig: () => + Stream.die(new Error("Config is not used by registry tests.")), ready: Effect.void, probe: Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 5e50c44d9610..d9f54bb326ca 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -163,6 +163,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: Effect.succeed({ client: TEST_RPC_CLIENT, initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 0cb1650066c4..36bc6a7b296f 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -57,6 +57,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct const session: RpcSession.RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137caccb..4e6baba8bef4 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,6 +1,8 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, type RelayClientInstallProgressEvent, + type ServerConfigStreamEvent, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -47,6 +49,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -77,6 +80,39 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reuses the session config stream instead of opening a duplicate subscription", () => + Effect.gen(function* () { + const event: ServerConfigStreamEvent = { + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }; + let duplicateSubscriptions = 0; + const client = { + [WS_METHODS.subscribeServerConfig]: () => { + duplicateSubscriptions += 1; + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + subscribeServerConfig: () => Stream.succeed(event), + }), + ); + + const received = yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( + Stream.runHead, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(received).toEqual(Option.some(event)); + expect(duplicateSubscriptions).toBe(0); + }), + ); + it.effect("observes unary requests until they complete", () => Effect.gen(function* () { const observations: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index bfe57a6c0dd5..50cc029eccff 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -203,7 +203,11 @@ export function subscribeDynamic( Option.match({ onNone: () => Stream.empty, onSome: (session) => { - const method = session.client[tag] as ( + const method = ( + tag === WS_METHODS.subscribeServerConfig + ? session.subscribeServerConfig + : session.client[tag] + ) as ( input: EnvironmentRpcInput, ) => Stream.Stream< EnvironmentRpcStreamValue, diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 0af5850bf6c7..aedd85c5de47 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1,24 +1,41 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, ServerConfig, type ServerConfig as ServerConfigType, + ServerConfigStreamEvent, + type ServerConfigStreamEvent as ServerConfigStreamEventType, 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"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; import * as Socket from "effect/unstable/socket/Socket"; import { + AVAILABLE_CONNECTION_STATE, + ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "./session.ts"; +import { makeEnvironmentServerConfigState } from "../state/server.ts"; +import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; type SocketEventType = "open" | "message" | "close" | "error"; type SocketEvent = { @@ -139,10 +156,24 @@ const RpcRequest = Schema.TaggedStruct("Request", { tag: Schema.String, }); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const isRpcRequest = Schema.is(RpcRequest); +const isPing = Schema.is(Schema.Struct({ _tag: Schema.Literal("Ping") })); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeServerConfig = Schema.encodeSync(ServerConfig); +const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); +const encodeDefect = Schema.encodeSync(Schema.Defect()); const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); +const THEME_SERVER_CONFIG: ServerConfigType = { + ...SERVER_CONFIG, + environment: { + ...SERVER_CONFIG.environment, + capabilities: { + ...SERVER_CONFIG.environment.capabilities, + environmentThemes: true, + }, + }, +}; +const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG); const LEGACY_SERVER_CONFIG = { ...ENCODED_SERVER_CONFIG, environment: { @@ -153,23 +184,26 @@ const LEGACY_SERVER_CONFIG = { }, }; -const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* ( + options: RpcSession.RpcSessionOptions = {}, +) { const sockets: TestWebSocket[] = []; const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { const socket = new TestWebSocket(url); sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const layer = RpcSession.layerWithOptions(options).pipe(Layer.provide(constructorLayer)); const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( sockets: ReadonlyArray, + index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const socket = sockets[0]; + const socket = sockets[index]; if (socket) { return socket; } @@ -183,9 +217,9 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const request = socket.sent[index]; + const request = socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)[index]; if (request) { - return decodeRpcRequest(decodeJson(request)); + return request; } yield* Effect.yieldNow; } @@ -195,21 +229,33 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( socket: TestWebSocket, config: unknown = ENCODED_SERVER_CONFIG, + payload: unknown = {}, ) { const request = yield* awaitRequest(socket); expect(request).toMatchObject({ _tag: "Request", - tag: WS_METHODS.serverGetConfig, - payload: {}, + tag: WS_METHODS.subscribeServerConfig, + payload, }); socket.serverMessage( encodeJson({ - _tag: "Exit", + _tag: "Chunk", requestId: request.id, - exit: { - _tag: "Success", - value: config, - }, + values: [{ version: 1, type: "snapshot", config }], + }), + ); +}); + +const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents")(function* ( + socket: TestWebSocket, + events: ReadonlyArray, +) { + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: events.map((event) => encodeServerConfigStreamEvent(event)), }), ); }); @@ -229,7 +275,9 @@ describe("RpcSessionFactory", () => { const config = yield* session.initialConfig; expect(config).toEqual(SERVER_CONFIG); - expect(socket.sent).toHaveLength(1); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); const probeFiber = yield* Effect.forkChild(session.probe); const probeRequest = yield* awaitRequest(socket, 1); @@ -250,19 +298,25 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverProbe, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverProbe]); socket.close(1012, "service restart"); const error = yield* Effect.flip(session.closed); + const configStreamError = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runDrain, Effect.flip); expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ reason: "transport", message: "Test environment disconnected.", }); + expect(configStreamError).toMatchObject({ _tag: "RpcClientError" }); yield* Effect.yieldNow; expect(sockets).toHaveLength(1); }), @@ -287,6 +341,602 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("replays current config and broadcasts updates to every subscriber", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const collectTwo = session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect); + const firstSubscriber = yield* Effect.forkChild(collectTwo); + const secondSubscriber = yield* Effect.forkChild(collectTwo); + yield* Effect.yieldNow; + + const shortcut = { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }; + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [ + { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [{ command: "terminal.toggle", shortcut }], + issues: [], + }, + }, + ], + }), + ); + + const firstEvents = Array.from(yield* Fiber.join(firstSubscriber)); + const secondEvents = Array.from(yield* Fiber.join(secondSubscriber)); + expect(firstEvents.map((event) => event.type)).toEqual(["snapshot", "keybindingsUpdated"]); + expect(secondEvents).toEqual(firstEvents); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: { keybindings: [{ command: "terminal.toggle", shortcut }] }, + }, + }); + }), + ), + ); + + it.effect("shares only a config subscription with the same theme opt-in", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const shared = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.runHead); + expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); + + const fallbackFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.forkChild); + const fallbackRequest = yield* awaitRequest(socket, 1); + expect(fallbackRequest).toMatchObject({ + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: fallbackRequest.id, + values: [ + { + version: 1, + type: "snapshot", + config: ENCODED_THEME_SERVER_CONFIG, + }, + ], + }), + ); + expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ + _tag: "Some", + value: { type: "snapshot" }, + }); + }), + ), + ); + + it.effect("replays theme updates and deletion as authoritative events", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const replacementThemes = [ + { + id: "midnight", + name: "Midnight", + appearance: "dark" as const, + canvas: "#000000", + accent: "#ffffff", + }, + ]; + const subscriberStarted = yield* Deferred.make(); + const subscriber = yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.mapEffect((event) => + Deferred.succeed(subscriberStarted, undefined).pipe(Effect.as(event)), + ), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscriberStarted); + yield* publishConfigEvents(socket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: replacementThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]); + + const liveEvents = Array.from(yield* Fiber.join(subscriber)); + expect(liveEvents.map((event) => event.type)).toEqual([ + "snapshot", + "environmentThemesUpdated", + "environmentThemesUpdated", + "environmentThemesUpdated", + ]); + expect(liveEvents[2]).toMatchObject({ payload: { themes: replacementThemes } }); + + const replay = Array.from( + yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.take(2), Stream.runCollect), + ); + expect(replay.map((event) => event.type)).toEqual(["snapshot", "environmentThemesUpdated"]); + expect(replay[1]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }); + for (const event of replay) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("recovers a slow subscriber after it misses theme deletion", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const slowSubscriberStarted = yield* Deferred.make(); + const releaseSlowSubscriber = yield* Deferred.make(); + let firstEvent = true; + const slowSubscriber = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe( + Stream.mapEffect((event) => { + if (!firstEvent) return Effect.succeed(event); + firstEvent = false; + return Deferred.succeed(slowSubscriberStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlowSubscriber)), + Effect.as(event), + ); + }), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(slowSubscriberStarted); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const themeEvents: ServerConfigStreamEventType[] = [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { + themes: [{ ...firstThemes[0]!, name: "Nightfall 2" }], + }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]; + const settingsEvents = Array.from( + { length: 65 }, + (): ServerConfigStreamEventType => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }), + ); + const allEvents = [...themeEvents, ...settingsEvents]; + const observedByFastSubscriber = yield* Queue.unbounded(); + yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)), + Effect.forkChild, + ); + expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot"); + for (const event of allEvents) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observedByFastSubscriber)).toEqual(event); + } + yield* Deferred.succeed(releaseSlowSubscriber, undefined); + + const recovered = Array.from(yield* Fiber.join(slowSubscriber)); + expect(recovered.map((event) => event.type)).toEqual([ + "snapshot", + "snapshot", + "environmentThemesUpdated", + ]); + expect(recovered[2]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, themeEvents[0]!); + for (const event of recovered.slice(1)) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("closes the session when the config source dies", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const closedFiber = yield* session.closed.pipe(Effect.exit, Effect.forkChild); + socket.serverMessage( + encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }), + ); + + const closed = yield* Fiber.join(closedFiber); + expect(Exit.isFailure(closed)).toBe(true); + if (Exit.isFailure(closed)) { + expect(Cause.hasDies(closed.cause)).toBe(true); + } + }), + ), + ); + + it.effect.each([{ failure: "defect" as const }, { failure: "typed" as const }])( + "keeps durable config state alive after an owned $failure failure", + ({ failure }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const firstSession = yield* factory.connect(PREPARED); + const firstReady = yield* Effect.forkChild(firstSession.ready); + const firstSocket = yield* awaitSocket(sockets); + firstSocket.open(); + yield* completeInitialConfig(firstSocket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(firstReady); + + const activeSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const configState = yield* makeEnvironmentServerConfigState(true).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + const awaitConfig = (predicate: (config: ServerConfigType) => boolean) => + SubscriptionRef.changes(configState).pipe( + Stream.filter(Option.isSome), + Stream.map((projection) => projection.value.config), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const firstThemes = [ + { + id: "first-theme", + name: "First theme", + appearance: "dark" as const, + canvas: "#111111", + accent: "#ffffff", + }, + ]; + const firstThemeState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "first-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(firstSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + ]); + expect((yield* Fiber.join(firstThemeState)).environmentThemes).toEqual(firstThemes); + + const firstClosed = yield* firstSession.closed.pipe(Effect.exit, Effect.forkChild); + const firstRequest = yield* awaitRequest(firstSocket); + firstSocket.serverMessage( + failure === "defect" + ? encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }) + : encodeJson({ + _tag: "Exit", + requestId: firstRequest.id, + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: "config subscription rejected", + requiredScope: "orchestration:read", + }, + }, + ], + }, + }), + ); + const firstClosedExit = yield* Fiber.join(firstClosed); + expect(Exit.isFailure(firstClosedExit)).toBe(true); + if (failure === "typed" && Exit.isFailure(firstClosedExit)) { + expect(Cause.squash(firstClosedExit.cause)).toBeInstanceOf(ConnectionBlockedError); + expect(Cause.squash(firstClosedExit.cause)).toMatchObject({ reason: "permission" }); + } + yield* SubscriptionRef.set(activeSession, Option.none()); + + const recoveredConfig = { + ...THEME_SERVER_CONFIG, + environment: { + ...THEME_SERVER_CONFIG.environment, + label: "Recovered environment", + }, + } satisfies ServerConfigType; + const secondSession = yield* factory.connect(PREPARED); + const secondReady = yield* Effect.forkChild(secondSession.ready); + const secondSocket = yield* awaitSocket(sockets, 1); + secondSocket.open(); + yield* completeInitialConfig(secondSocket, encodeServerConfig(recoveredConfig), { + environmentThemes: true, + }); + yield* Fiber.join(secondReady); + + const recoveredState = yield* awaitConfig( + (config) => config.environment.label === "Recovered environment", + ).pipe(Effect.forkChild); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + expect((yield* Fiber.join(recoveredState)).environmentThemes).toEqual(firstThemes); + + const recoveredThemes = [ + { + id: "recovered-theme", + name: "Recovered theme", + appearance: "dark" as const, + canvas: "#000000", + accent: "#eeeeee", + }, + ]; + const liveRecoveredState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "recovered-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(secondSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: recoveredThemes }, + }, + ]); + expect((yield* Fiber.join(liveRecoveredState)).environmentThemes).toEqual( + recoveredThemes, + ); + }), + ), + ); + + it.effect.each<{ + readonly event: ServerConfigStreamEventType; + readonly expectedConfig: Partial; + }>([ + { + event: { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + expectedConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + { + event: { + version: 1, + type: "settingsUpdated", + payload: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + expectedConfig: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + ])( + "preserves $event.type events and includes them in replay snapshots", + ({ event, expectedConfig }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const subscriber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + yield* Effect.yieldNow; + + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [encodeServerConfigStreamEvent(event)], + }), + ); + + const events = Array.from(yield* Fiber.join(subscriber)); + expect(events[1]).toEqual(event); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: expectedConfig, + }, + }); + }), + ), + ); + it.effect("tolerates two missed pong windows before closing the session", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); @@ -301,7 +951,7 @@ describe("RpcSessionFactory", () => { yield* TestClock.adjust("15 seconds"); expect(closedFiber.pollUnsafe()).toBeUndefined(); - expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + expect(socket.sent.map((message) => decodeJson(message)).filter(isPing)).toEqual([ { _tag: "Ping" }, { _tag: "Ping" }, { _tag: "Ping" }, @@ -379,10 +1029,12 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverGetConfig, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverGetConfig]); }), ), ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9625effa406f..7d975be5c9d3 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,11 +1,25 @@ -import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { + type ServerConfig, + type ServerConfigStreamEvent, + WsSubscribeServerConfigRpc, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; @@ -19,15 +33,27 @@ import { ConnectionBlockedError, ConnectionTransientError as ConnectionTransientErrorClass, } from "../connection/model.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "../state/serverConfigProjection.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; + readonly subscribeServerConfig: ( + input: ServerConfigSubscriptionInput, + ) => ServerConfigSubscription; readonly ready: Effect.Effect; readonly probe: Effect.Effect; - readonly closed: Effect.Effect; + readonly closed: Effect.Effect; +} + +export interface RpcSessionOptions { + readonly environmentThemes?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -43,8 +69,47 @@ type InitialConfigError = Effect.Error< ReturnType >; type ProbeError = Effect.Error>; +type ServerConfigSubscriptionError = + | Rpc.ErrorExit + | RpcClientError.RpcClientError; +type ServerConfigSubscription = Stream.Stream< + ServerConfigStreamEvent, + ServerConfigSubscriptionError +>; +type ServerConfigSubscriptionInput = Parameters< + WsRpcProtocolClient[typeof WS_METHODS.subscribeServerConfig] +>[0]; +type EnvironmentThemesUpdatedEvent = Extract< + ServerConfigStreamEvent, + { readonly type: "environmentThemesUpdated" } +>; + +interface ServerConfigReplayState { + readonly projection: ServerConfigProjection; + readonly revision: number; + readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined; +} + +interface BufferedServerConfigEvent { + readonly event: ServerConfigStreamEvent; + readonly replay: ServerConfigReplayState; + readonly revision: number; +} + +function serverConfigReplayEvents( + state: ServerConfigReplayState, +): ReadonlyArray { + const snapshot = { + version: 1 as const, + type: "snapshot" as const, + config: withoutEnvironmentThemes(state.projection.config), + }; + return state.themesEvent === undefined ? [snapshot] : [snapshot, state.themesEvent]; +} -function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError { +function mapSessionRpcError( + error: InitialConfigError | ProbeError | ServerConfigSubscriptionError, +): ConnectionAttemptError { switch (error._tag) { case "EnvironmentAuthorizationError": return new ConnectionBlockedError({ @@ -65,8 +130,12 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA } } -export const make = Effect.gen(function* () { +export const make = Effect.fn("RpcSessionFactory.make")(function* ( + options: RpcSessionOptions = {}, +) { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const serverConfigInput: ServerConfigSubscriptionInput = + options.environmentThemes === true ? { environmentThemes: true } : {}; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { yield* Effect.annotateCurrentSpan({ @@ -113,18 +182,135 @@ export const make = Effect.gen(function* () { const protocolContext = yield* Layer.build(protocolLayer).pipe( Effect.withSpan("environment.websocket.connect"), ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( + const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfigDeferred = yield* Deferred.make(); + const serverConfigExit = yield* Deferred.make(); + const configSubscriptionClosed = yield* Deferred.make(); + const serverConfigState = yield* Ref.make(Option.none()); + const serverConfigUpdates = yield* PubSub.sliding(64); + const configSubscriptionEndedError = new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + detail: `${connection.label} config subscription ended.`, + }); + const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( + serverConfigInput, + ).pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + const buffered = yield* Ref.modify(serverConfigState, (current) => { + const projection = applyServerConfigProjection( + Option.map(current, (state) => state.projection), + event, + ); + if (Option.isNone(projection)) { + return [Option.none(), current] as const; + } + const next = { + projection: projection.value, + revision: Option.match(current, { + onNone: () => 1, + onSome: (state) => state.revision + 1, + }), + themesEvent: + event.type === "environmentThemesUpdated" + ? event + : event.type === "snapshot" && + event.config.environment.capabilities.environmentThemes !== true + ? undefined + : Option.getOrUndefined(current)?.themesEvent, + } satisfies ServerConfigReplayState; + return [ + Option.some({ event, replay: next, revision: next.revision }), + Option.some(next), + ] as const; + }); + if (Option.isSome(buffered)) { + yield* PubSub.publish(serverConfigUpdates, buffered.value); + } + if (event.type === "snapshot") { + yield* Deferred.succeed(initialConfigDeferred, event.config); + } + }), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) { + return Effect.all([ + Deferred.succeed(serverConfigExit, undefined), + Deferred.fail(configSubscriptionClosed, configSubscriptionEndedError), + ]).pipe(Effect.asVoid); + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return Effect.void; + } + return Effect.all([ + Deferred.failCause(serverConfigExit, exit.cause), + Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapSessionRpcError)), + ]).pipe(Effect.asVoid); + }), + ); + yield* serverConfigSource.pipe(Effect.forkScoped); + const initialConfig = Effect.raceFirst( + Deferred.await(initialConfigDeferred), + Deferred.await(serverConfigExit).pipe( Effect.mapError(mapSessionRpcError), - Effect.withSpan("environment.initialSync"), + Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), + ).pipe(Effect.withSpan("environment.initialSync")); + const serverConfigEvents = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(serverConfigUpdates); + yield* Effect.raceFirst( + Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), + Deferred.await(serverConfigExit), + ); + const snapshot = yield* Ref.get(serverConfigState); + if (Option.isNone(snapshot)) { + return Stream.empty; + } + const updates = Stream.fromSubscription(subscription).pipe( + Stream.filter((buffered) => buffered.revision > snapshot.value.revision), + Stream.mapAccum( + () => snapshot.value.revision, + (revision, buffered) => [ + buffered.revision, + buffered.revision === revision + 1 + ? [buffered.event] + : serverConfigReplayEvents(buffered.replay), + ], + ), + ); + const terminal = Stream.fromEffect(Deferred.await(serverConfigExit)).pipe(Stream.drain); + return Stream.concat( + Stream.fromIterable(serverConfigReplayEvents(snapshot.value)), + Stream.merge(updates, terminal, { haltStrategy: "either" }), + ); + }), + ).pipe( + Stream.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Stream.failCause(cause); + } + // The supervisor keeps the original cause. Shared durable consumers + // need a transport-shaped failure so they wait for its replacement. + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `${connection.label} config subscription failed.`, + cause, + }), + }), + ); + }), ); + const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => + Equal.equals(input, serverConfigInput) + ? serverConfigEvents + : protocolClient[WS_METHODS.subscribeServerConfig](input); const probe = initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true - ? client[WS_METHODS.serverProbe]({}) - : client[WS_METHODS.serverGetConfig]({}) + ? protocolClient[WS_METHODS.serverProbe]({}) + : protocolClient[WS_METHODS.serverGetConfig]({}) ).pipe(Effect.mapError(mapSessionRpcError)), ), Effect.asVoid, @@ -132,19 +318,26 @@ export const make = Effect.gen(function* () { ); return { - client, + client: protocolClient, initialConfig, + subscribeServerConfig, ready: Deferred.await(connected).pipe( Effect.andThen(initialConfig), Effect.asVoid, Effect.raceFirst(Deferred.await(disconnected)), ), probe, - closed: Deferred.await(disconnected), + closed: Effect.raceFirst( + Deferred.await(disconnected), + Deferred.await(configSubscriptionClosed), + ), } satisfies RpcSession; }); return RpcSessionFactory.of({ connect }); }); -export const layer = Layer.effect(RpcSessionFactory, make); +export const layerWithOptions = (options: RpcSessionOptions) => + Layer.effect(RpcSessionFactory, make(options)); + +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index e5dde9d8427c..618d5c39418b 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -32,6 +32,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 17dc09998fbf..ea170c22e830 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -30,7 +30,6 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { - applyServerConfigProjection, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, @@ -42,6 +41,7 @@ import { serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, } from "./server.ts"; +import { applyServerConfigProjection } from "./serverConfigProjection.ts"; const CONFIG = { availableEditors: [], @@ -73,6 +73,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.succeed(CONFIG), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 547fa4a3af28..176b2631e0c5 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -41,6 +41,14 @@ import { type EnvironmentRpcInput, } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "./serverConfigProjection.ts"; + +// Exported server state includes this type in its inferred public return type. +export type { ServerConfigProjection } from "./serverConfigProjection.ts"; export type ServerUpdateStage = "downloading" | "installing" | "resuming"; @@ -262,75 +270,6 @@ export function resolveServerUpdateProgressResult( return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); } -export interface ServerConfigProjection { - readonly config: ServerConfig; - readonly latestEvent: ServerConfigStreamEvent; - readonly source: "cache" | "live"; -} - -export function applyServerConfigProjection( - current: Option.Option, - event: ServerConfigStreamEvent, -): Option.Option { - switch (event.type) { - case "snapshot": { - // A snapshot never carries published themes -- the theme stream owns - // them -- so taking it wholesale would clear the set on every reconnect - // and repaint anyone wearing one until the follow-up event landed. - // Only from a server that still streams them. Reconnecting to one that - // predates the feature must drop the set rather than leave a palette on - // screen that nothing will ever update again. - const carried = - event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) - ? current.value.config.environmentThemes - : undefined; - return Option.some({ - config: - carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, - latestEvent: event, - source: "live" as const, - }); - } - case "keybindingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - }, - latestEvent: event, - source: "live", - })); - case "providerStatuses": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - providers: event.payload.providers, - }, - latestEvent: event, - source: "live", - })); - case "settingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - settings: event.payload.settings, - }, - latestEvent: event, - source: "live", - })); - case "environmentThemesUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, - }, - latestEvent: event, - source: "live", - })); - } -} - export function projectServerConfig( current: Option.Option, event: ServerConfigStreamEvent, @@ -345,22 +284,6 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven config, }); -/** - * Keeps a complete server configuration available during reconnects. Server - * config carries the provider/model catalogue used by task creation, so it is - * useful—and safe—to retain after a transport session ends. - */ -/** - * Published themes live only as long as the machine publishes them, so they - * must not survive in the config cache: a restart or an offline load would - * otherwise hand clients palettes the environment has already dropped. - */ -function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { - if (config.environmentThemes === undefined) return config; - const { environmentThemes: _ephemeral, ...rest } = config; - return rest; -} - export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( function* (environmentThemes?: boolean) { const supervisor = yield* EnvironmentSupervisor; diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts new file mode 100644 index 000000000000..6f4a812cf7e9 --- /dev/null +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -0,0 +1,79 @@ +import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; + readonly source: "cache" | "live"; +} + +/** + * Cached config keeps the provider and model catalog available across reconnects. + * Published themes are current machine state, so a cache could restore themes + * that the machine no longer publishes. Replay sends themes as a separate event. + */ +export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { + if (config.environmentThemes === undefined) return config; + const { environmentThemes: _ephemeral, ...rest } = config; + return rest; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": { + // Wire snapshots never contain published themes. Keep the previous set + // until a capable server sends its authoritative theme event. A legacy + // server cannot send a later removal, so a downgrade must clear the set. + const carried = + event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) + ? current.value.config.environmentThemes + : undefined; + return Option.some({ + config: + carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, + latestEvent: event, + source: "live" as const, + }); + } + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + source: "live", + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + source: "live", + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + source: "live", + })); + case "environmentThemesUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, + }, + latestEvent: event, + source: "live", + })); + } +} diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 40e9bd80dc5b..1c0d838026fb 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -51,6 +51,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 393be8e3227d..33c566bf82b6 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -50,6 +50,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 62cad18f89e0..2cede4f5b3e2 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt initialConfig: Effect.succeed({ threadSnapshotPagination: options?.paginationCapability !== false, } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c2df434e8e77..d94ed3a3fd74 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -112,6 +112,7 @@ function testSession( ? ({ threadResumeCompletionMarker: true } as never) : ({} as never), ), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c62078..d7a4692fc317 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -86,6 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b936246dc823..905972975606 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -84,6 +84,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, From 02163fdb6e0a1bd2d7b06238b67ee36241006a67 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:15:34 +0200 Subject: [PATCH 42/42] test(web): include fork catalog hits for settings search "work" New-threads, origin worktrees, environment artwork, and network-access also match the substring after the upstream catalog weld. --- apps/web/src/components/settings/settingsSearch.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index d1643ec213d1..159114673d6e 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -57,7 +57,13 @@ describe("searchSettings", () => { expect( searchSettings("work", [{ id: "word-wrap", title: "Word wrap", to: "/settings/appearance" }]), ).toEqual([]); - expect(searchSettings("work").map((item) => item.id)).toEqual(["worktree-remove-confirmation"]); + expect(searchSettings("work").map((item) => item.id)).toEqual([ + "worktree-remove-confirmation", + "network-access", + "environment-identification", + "new-threads", + "start-from-origin", + ]); expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme"); const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");